refactor(#4653): replace the allowAbsolute flag with a named acceptance policy
Phase 3 of epic #4636, stage 3b. Satisfies #4653's criterion that `opts.allowAbsolute` become "a named acceptance policy on the predicate, not a per-call-site boolean". The flag was actively misleading at the call site. `{ allowAbsolute: true }` reads as "containment is relaxed here". It never was: an absolute path that resolves outside the root is rejected exactly as a traversal is. The flag only ever controlled whether an absolute candidate was CONSIDERED. On a security predicate that is the wrong thing for a reviewer to have to infer, and 31 call sites were asking them to infer it. PathAcceptance.RelativeOnly relative candidates only PathAcceptance.AbsoluteInsideRoot absolute accepted, containment unchanged The three exported wrappers take the policy and translate it inward. validatePath keeps its internal `{ allowAbsolute }` opts and its body untouched — the engine is not re-derived here either, only the exported surface is renamed. MEASURED, NOT ESTIMATED. 31 call sites across 10 files, counted by walking the AST with the repo's own @typescript-eslint/parser rather than grepping: a text match would have folded in the options-type declaration, default parameter values and comments. All 31 pass the literal `true`; none passes `false` or a dynamic value, so the migration is uniform and `RelativeOnly` is purely the existing default made nameable. audit.cts alone holds 18 of them. This migration is compiler-verified in a way the containment-value migration in the previous commit was not: the parameter type changed from an object to a string union, so any missed site is a build error rather than a silent behavioral difference. That is why a 31-site mechanical edit is acceptable in the phase whose stated risk is the width of mechanical change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -196,12 +196,37 @@ export function loadTrustedGlobalRoots(config: unknown): string[] {
|
||||
*/
|
||||
export type ContainedPath = string & { readonly __containedIn: unique symbol };
|
||||
|
||||
/**
|
||||
* Named acceptance policy for what kind of candidate path is even considered.
|
||||
*
|
||||
* This replaces the old per-call-site `{ allowAbsolute: true }` boolean flag.
|
||||
* At a call site, `{ allowAbsolute: true }` reads as "containment is relaxed
|
||||
* here" — which is FALSE. An absolute path that resolves OUTSIDE the root is
|
||||
* still rejected; the flag only ever controlled whether an absolute candidate
|
||||
* was considered at all. `AbsoluteInsideRoot` states the real contract: an
|
||||
* absolute candidate is accepted for consideration, but containment is
|
||||
* enforced exactly as it is for a relative one.
|
||||
*/
|
||||
export const PathAcceptance = {
|
||||
/** Relative candidates only; an absolute candidate is rejected outright. */
|
||||
RelativeOnly: 'relative-only',
|
||||
/**
|
||||
* An absolute candidate is accepted — but ONLY if it still resolves inside the
|
||||
* root. Containment is NOT relaxed by this policy; an absolute path outside the
|
||||
* root is rejected exactly as a traversal is. This is the distinction the old
|
||||
* `{ allowAbsolute: true }` flag failed to make at its call sites.
|
||||
*/
|
||||
AbsoluteInsideRoot: 'absolute-inside-root',
|
||||
} as const;
|
||||
|
||||
export type PathAcceptancePolicy = (typeof PathAcceptance)[keyof typeof PathAcceptance];
|
||||
|
||||
/**
|
||||
* Validate a file path and throw on traversal attempt.
|
||||
* Convenience wrapper around validatePath for use in CLI commands.
|
||||
*/
|
||||
export function assertWithinRoot(candidate: unknown, root: unknown, label?: string | null, opts: { allowAbsolute?: boolean } = {}): ContainedPath {
|
||||
const result = validatePath(candidate, root, opts);
|
||||
export function assertWithinRoot(candidate: unknown, root: unknown, label?: string | null, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath {
|
||||
const result = validatePath(candidate, root, { allowAbsolute: policy === PathAcceptance.AbsoluteInsideRoot });
|
||||
if (!result.safe) {
|
||||
throw new Error(`${label || 'Path'} validation failed: ${result.error}`);
|
||||
}
|
||||
@@ -216,8 +241,8 @@ export function assertWithinRoot(candidate: unknown, root: unknown, label?: stri
|
||||
* traversal branch, so returning it here would reproduce the defect this
|
||||
* narrowing exists to remove.
|
||||
*/
|
||||
export function tryWithinRoot(candidate: unknown, root: unknown, opts: { allowAbsolute?: boolean } = {}): ContainedPath | null {
|
||||
const result = validatePath(candidate, root, opts);
|
||||
export function tryWithinRoot(candidate: unknown, root: unknown, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath | null {
|
||||
const result = validatePath(candidate, root, { allowAbsolute: policy === PathAcceptance.AbsoluteInsideRoot });
|
||||
if (!result.safe) {
|
||||
return null;
|
||||
}
|
||||
@@ -232,8 +257,8 @@ export function tryWithinRoot(candidate: unknown, root: unknown, opts: { allowAb
|
||||
* names; its declared return type is ContainedPath (a branded string, still
|
||||
* assignable to string) so existing callers keep compiling untouched.
|
||||
*/
|
||||
export function requireSafePath(filePath: unknown, baseDir: unknown, label: string | null | undefined, opts: { allowAbsolute?: boolean } = {}): ContainedPath {
|
||||
return assertWithinRoot(filePath, baseDir, label, opts);
|
||||
export function requireSafePath(filePath: unknown, baseDir: unknown, label: string | null | undefined, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath {
|
||||
return assertWithinRoot(filePath, baseDir, label, policy);
|
||||
}
|
||||
|
||||
// ─── Prompt Injection Detection ────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user