fix(hooks): close F-5 path-traversal hardening in lib.mjs, split by field
session_id becomes a raw filename segment in sessionStateFile(), so an unvalidated value could escape STATE_DIR via path traversal (verified with a failing test before the fix). Now allowlisted to ^[A-Za-z0-9_-]+$, with invalid values degrading to a fixed sentinel filename rather than blocking the hook. cwd is a base directory, not a segment, and every real value contains "/" — applying the same allowlist as the review's literal suggestion would reject all legitimate absolute paths and silently disable the project-level config override. initConfig() instead guards with isAbsolute(cwd) && no NUL byte. Both harness-supplied, not user-controlled: defense-in-depth, not a fix for an observed exploit. Tests added for the escape (red before fix, green after) and for the cwd regression (a normal absolute cwd still loads project config). Full resolution notes in docs/review-2026-06-20.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSATejUPjGaxGnj9jkFQTo
This commit is contained in:
parent
b4898746c1
commit
736a1c0deb
4 changed files with 93 additions and 7 deletions
|
|
@ -3,7 +3,7 @@
|
|||
// Zero npm dependencies — Node.js stdlib only.
|
||||
|
||||
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { join, isAbsolute } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// --- Stdin ---
|
||||
|
|
@ -54,9 +54,14 @@ let LAYER4_ENABLED = false;
|
|||
export function initConfig() {
|
||||
const cwd = getField('cwd');
|
||||
|
||||
// Project-level config takes precedence over global
|
||||
// Project-level config takes precedence over global. cwd is a base
|
||||
// directory, not a filename segment, so it isn't put through the
|
||||
// session_id allowlist below — only rejected if it isn't a well-formed
|
||||
// absolute path (defends against embedded NUL bytes; see F-5).
|
||||
const candidates = [];
|
||||
if (cwd) candidates.push(join(cwd, '.claude', 'ai-psychosis.local.md'));
|
||||
if (cwd && isAbsolute(cwd) && !cwd.includes('\0')) {
|
||||
candidates.push(join(cwd, '.claude', 'ai-psychosis.local.md'));
|
||||
}
|
||||
candidates.push(join(homedir(), '.claude', 'ai-psychosis.local.md'));
|
||||
|
||||
let content;
|
||||
|
|
@ -228,8 +233,16 @@ export function readRecentEndRecords(n) {
|
|||
|
||||
// --- State file management ---
|
||||
|
||||
// session_id becomes a raw filename segment, so an unvalidated value (e.g.
|
||||
// containing "../") could escape STATE_DIR via path traversal. Harness-supplied,
|
||||
// not user-controlled — this is defense-in-depth hardening (F-5), not a fix for
|
||||
// an observed exploit. Values that fail the allowlist degrade to a fixed
|
||||
// sentinel filename rather than blocking the hook.
|
||||
const SAFE_ID_RE = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function sessionStateFile(sid) {
|
||||
sid = sid || getSessionId();
|
||||
if (!SAFE_ID_RE.test(sid)) sid = 'invalid-session-id';
|
||||
return join(STATE_DIR, `${sid}.json`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue