path-security.ts
132 lines · browse/src/path-security.ts at 3f79d81
1/**
2 * Shared path validation — single source of truth for file path security.
3 *
4 * Previously duplicated across write-commands.ts, meta-commands.ts, and read-commands.ts.
5 * All file I/O commands (screenshot, pdf, download, scrape, archive, eval) must
6 * validate paths through these functions.
7 *
8 * validateOutputPath(path) — for writing files (screenshot, pdf, download, scrape, archive)
9 * validateReadPath(path) — for reading files (eval)
10 * validateTempPath(path) — for serving files to remote agents (GET /file, TEMP_DIR only)
11 *
12 * Security invariants:
13 * 1. All paths resolved to absolute before checking
14 * 2. Symlinks resolved to catch traversal via symlink inside safe dir
15 * 3. SAFE_DIRECTORIES = [...TEMP_DIRS, cwd] for local commands (TEMP_DIRS =
16 * classic TEMP_DIR plus os.tmpdir(), which differ on macOS and under
17 * TMPDIR-honoring CI/sandbox environments)
18 * 4. TEMP_ONLY = [TEMP_DIR] for remote file serving (prevents project file exfil)
19 * 5. Safe directories and targets go through the same realpath (#3104):
20 * on Windows `fs.realpathSync` keeps an 8.3 short name such as
21 * C:\Users\BURCIN~1 while `fs.realpathSync.native` expands it, so mixing
22 * them rejected paths inside the temp dir. Resolved paths compare exactly;
23 * only a tail that does not exist yet is case-folded on Windows.
24 */
26import * as fs from 'fs';
27import * as path from 'path';
28import { TEMP_DIR, TEMP_DIRS, isPathWithin } from './platform';
30const realpath = fs.realpathSync.native;
31const foldMissing = (component: string) => process.platform === 'win32' ? component.toLowerCase() : component;
33/**
34 * Resolve the longest existing prefix of `filePath` with the one realpath
35 * function and append the missing tail, case-folded on Windows. Throws for
36 * resolution errors other than ENOENT.
37 */
38export function resolveRealPath(filePath: string): string {
39 const absolute = path.resolve(filePath);
40 try {
41 return realpath(absolute);
42 } catch (err: any) {
43 const parent = path.dirname(absolute);
44 if (err?.code !== 'ENOENT' || parent === absolute) throw err;
45 return path.join(resolveRealPath(parent), foldMissing(path.basename(absolute)));
46 }
47}
49const resolveSafeDir = (dir: string) => {
50 try { return resolveRealPath(dir); } catch { return path.resolve(dir); }
51};
53// Resolved like every target, so symlinks (macOS /tmp → /private/tmp) and
54// Windows 8.3 names compare equal.
55export const SAFE_DIRECTORIES = [...TEMP_DIRS, process.cwd()].map(resolveSafeDir);
57const TEMP_ONLY = [TEMP_DIR].map(resolveSafeDir);
59/**
60 * Validate a file path for writing (screenshot, pdf, download, scrape, archive).
61 *
62 * Walks the path one component at a time the way the kernel will, resolving
63 * every existing component (symlinks included) before applying the next, so
64 * `link/../x` follows `link` first. A component that does not exist yet must
65 * already sit inside a safe directory; a dangling symlink is rejected because
66 * a write would create its target. Both the caller's spelling and its lexical
67 * normalization are checked, since callers write with either.
68 */
69export function validateOutputPath(filePath: string): void {
70 const reject = () => { throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); };
71 for (const spelling of new Set([filePath, path.resolve(filePath)])) {
72 const root = path.parse(spelling).root;
73 let resolved = path.resolve(root || '.');
74 for (const component of spelling.slice(root.length).split(process.platform === 'win32' ? /[\\/]+/ : /\/+/)) {
75 if (!component || component === '.') continue;
76 if (component === '..') {
77 resolved = path.dirname(resolved);
78 continue;
79 }
80 const next = path.join(resolved, component);
81 try {
82 fs.lstatSync(next);
83 } catch (err: any) {
84 resolved = path.join(resolved, foldMissing(component));
85 if (err?.code === 'ENOENT' && SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir))) continue;
86 reject();
87 }
88 try {
89 resolved = realpath(next);
90 } catch {
91 reject();
92 }
93 }
94 if (!SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir))) reject();
95 }
96}
98/** Validate a file path for reading (eval command). */
99export function validateReadPath(filePath: string): void {
100 let realPath: string;
101 try {
102 realPath = resolveRealPath(filePath);
103 } catch (err: any) {
104 throw new Error(`Cannot resolve real path: ${filePath} (${err.code})`);
105 }
106 const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realPath, dir));
107 if (!isSafe) {
108 throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
109 }
110}
112/** Validate a file path for remote serving (GET /file). TEMP_DIR only, not cwd. */
113export function validateTempPath(filePath: string): void {
114 let realPath: string;
115 try {
116 realPath = realpath(path.resolve(filePath));
117 } catch (err: any) {
118 if (err.code === 'ENOENT') {
119 throw new Error('File not found');
120 }
121 throw new Error(`Cannot resolve path: ${filePath}`);
122 }
123 const isSafe = TEMP_ONLY.some(dir => isPathWithin(realPath, dir));
124 if (!isSafe) {
125 throw new Error(`Path must be within: ${TEMP_ONLY.join(', ')} (remote file serving is restricted to temp directory)`);
126 }
127}
129/** Escape special regex metacharacters in a user-supplied string to prevent ReDoS. */
130export function escapeRegExp(s: string): string {
131 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
132}