-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathsafePath.ts
More file actions
72 lines (67 loc) · 2.98 KB
/
Copy pathsafePath.ts
File metadata and controls
72 lines (67 loc) · 2.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import { resolve, sep, join, dirname, basename } from "node:path";
import { realpathSync } from "node:fs";
/**
* Reject paths that escape the `base` directory — including via symlinks.
*
* `path.resolve()` collapses `.`/`..` but does NOT dereference symlinks, so a
* plain prefix check (`resolved.startsWith(base + sep)`) can be defeated by a
* symlink that lives *inside* `base` but points outside it (e.g.
* `base/link -> /etc`). A downstream `readFileSync`/`writeFileSync`/`statSync`
* then follows that link to a file outside `base`. To close this we canonicalize
* both sides with `realpathSync` before comparing.
*
* The target may not exist yet (e.g. creating a new file), so we canonicalize the
* deepest *existing* ancestor and re-attach the trailing not-yet-existing
* segments. Segments that don't exist cannot be symlinks at check time, so they
* can't redirect the path outside `base` right now. (A symlink swapped in between
* this check and the subsequent fs call is an inherent TOCTOU race this helper
* does not, and cannot by itself, defend against.)
*
* Lives at the package root rather than under `studio-api/` because callers span
* layers — `studio-api` routes, the `compiler`, the CLI, and the engine — and
* `compiler` sits below `studio-api` in the dependency graph, so it cannot import
* from there without a backwards edge.
*/
export function isSafePath(base: string, resolved: string): boolean {
let baseReal: string;
try {
baseReal = realpathSync(resolve(base));
} catch {
// Base must exist and be resolvable; fail closed if not.
return false;
}
const target = resolve(resolved);
const trailing: string[] = [];
let probe = target;
for (;;) {
let ancestorReal: string;
try {
ancestorReal = realpathSync(probe);
} catch {
const parent = dirname(probe);
if (parent === probe) return false; // walked past the filesystem root
trailing.push(basename(probe));
probe = parent;
continue;
}
// Copy before reverse(): the array is only consumed once today, but a future
// edit that loops would otherwise silently misorder the rebuilt segments.
const targetReal = trailing.length
? join(ancestorReal, ...[...trailing].reverse())
: ancestorReal;
return targetReal === baseReal || targetReal.startsWith(baseReal + sep);
}
}
/**
* Resolve `relativePath` against `base` and return the absolute path only if it
* stays within `base` (after symlink resolution); otherwise return `null`.
*
* Prefer this over a bare `resolve()` followed by a separate `isSafePath()`
* check: collapsing the two into one call means a caller cannot resolve a
* project-relative path and then forget the containment guard — the gap that
* let the symlink-escape slip past several call sites historically.
*/
export function resolveWithinProject(base: string, relativePath: string): string | null {
const resolved = resolve(base, relativePath);
return isSafePath(base, resolved) ? resolved : null;
}