|
1 | 1 | import fsp from 'node:fs/promises' |
2 | | -import { dirname, normalize, resolve } from 'pathe' |
| 2 | +import { normalize, resolve } from 'pathe' |
3 | 3 | import { diagnostics } from '../diagnostics' |
4 | 4 |
|
5 | | -/** |
6 | | - * Resolve a client-supplied, root-relative path against the managed |
7 | | - * directory, rejecting anything that would escape it (`..` traversal, a |
8 | | - * rogue absolute path, etc.). This is the lexical guard every RPC handler |
9 | | - * that touches the filesystem goes through first — never trust a path from |
10 | | - * the wire. |
11 | | - * |
12 | | - * Lexical checks alone cannot see symlinks: use {@link resolveAssetReadPath} |
13 | | - * (reads) or {@link assertAssetMutationPath} (mutations) to also close |
14 | | - * pre-existing symlink escapes. |
15 | | - */ |
16 | | -export function resolveAssetPath(root: string, relativePath: string): string { |
17 | | - const cleaned = relativePath.replace(/^[/\\]+/, '') |
18 | | - const normalizedRoot = resolve(root) |
19 | | - const absolute = resolve(normalizedRoot, cleaned) |
20 | | - if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}/`)) |
21 | | - throw diagnostics.DP_ASSETS_0001({ path: relativePath }) |
22 | | - return absolute |
23 | | -} |
24 | | - |
25 | | -/** `child === root` or a path nested beneath it, using pathe's `/` separator. */ |
26 | | -function isWithin(child: string, root: string): boolean { |
27 | | - return child === root || child.startsWith(`${root}/`) |
28 | | -} |
29 | | - |
30 | | -/** |
31 | | - * The canonical (symlink-resolved) managed root. Falls back to the lexical |
32 | | - * path when the directory does not exist yet. |
33 | | - */ |
34 | | -async function canonicalRoot(root: string): Promise<string> { |
35 | | - const normalizedRoot = resolve(root) |
| 5 | +/** realpath, pathe-normalized, or `null` when the path doesn't exist. */ |
| 6 | +async function realpath(path: string): Promise<string | null> { |
36 | 7 | try { |
37 | | - return normalize(await fsp.realpath(normalizedRoot)) |
| 8 | + return normalize(await fsp.realpath(path)) |
38 | 9 | } |
39 | 10 | catch { |
40 | | - return normalizedRoot |
| 11 | + return null |
41 | 12 | } |
42 | 13 | } |
43 | 14 |
|
44 | 15 | /** |
45 | | - * Canonical path of the nearest existing ancestor of `absolute` (the target |
46 | | - * itself when it exists), with every symlink along the way resolved. |
| 16 | + * Resolve a client-supplied, root-relative path against the managed |
| 17 | + * directory, rejecting anything that would escape it lexically (`..` |
| 18 | + * traversal, a rogue absolute path). The first guard every RPC handler runs; |
| 19 | + * symlink-aware containment is layered on by {@link resolveAssetReadPath} |
| 20 | + * (reads) and {@link assertAssetMutationPath} (mutations). |
47 | 21 | */ |
48 | | -async function nearestExistingCanonical(absolute: string): Promise<string> { |
49 | | - let current = absolute |
50 | | - for (;;) { |
51 | | - try { |
52 | | - return normalize(await fsp.realpath(current)) |
53 | | - } |
54 | | - catch { |
55 | | - const parent = dirname(current) |
56 | | - if (parent === current) |
57 | | - return current |
58 | | - current = parent |
59 | | - } |
60 | | - } |
| 22 | +export function resolveAssetPath(root: string, relativePath: string): string { |
| 23 | + const normalizedRoot = resolve(root) |
| 24 | + const absolute = resolve(normalizedRoot, relativePath.replace(/^[/\\]+/, '')) |
| 25 | + if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}/`)) |
| 26 | + throw diagnostics.DP_ASSETS_0001({ path: relativePath }) |
| 27 | + return absolute |
61 | 28 | } |
62 | 29 |
|
63 | 30 | /** |
64 | 31 | * Resolve a path for a **read**, allowing a symlink only when its canonical |
65 | | - * target stays inside the canonical managed root. Lexical escapes and |
66 | | - * symlinks whose canonical target leaves the root both throw |
67 | | - * `DP_ASSETS_0001`. |
| 32 | + * target stays inside the canonical managed root. A target resolving outside |
| 33 | + * throws `DP_ASSETS_0001`; a missing target is left for the caller's own read |
| 34 | + * to fail. |
68 | 35 | */ |
69 | 36 | export async function resolveAssetReadPath(root: string, relativePath: string): Promise<string> { |
70 | 37 | const absolute = resolveAssetPath(root, relativePath) |
71 | | - const canonRoot = await canonicalRoot(root) |
72 | | - const nearest = await nearestExistingCanonical(absolute) |
73 | | - if (!isWithin(nearest, canonRoot)) |
| 38 | + const real = await realpath(absolute) |
| 39 | + const canonRoot = (await realpath(root)) ?? resolve(root) |
| 40 | + if (real && real !== canonRoot && !real.startsWith(`${canonRoot}/`)) |
74 | 41 | throw diagnostics.DP_ASSETS_0001({ path: relativePath }) |
75 | 42 | return absolute |
76 | 43 | } |
77 | 44 |
|
78 | 45 | /** |
79 | 46 | * Resolve a path for a **mutation**, rejecting every pre-existing symlink |
80 | | - * among the path components from the managed root down to the target — |
81 | | - * including in-root symlinks — so a mutation can never follow a symlink out |
82 | | - * of (or around) the root. Walks only components that already exist, so it |
83 | | - * is safe for not-yet-created upload/mkdir targets; call it again after |
84 | | - * creating directories and immediately before the mutating I/O to re-check |
85 | | - * the freshly materialized components. |
86 | | - * |
87 | | - * This closes deterministic, pre-existing symlink escapes; it does not |
88 | | - * defeat a concurrent local process swapping a component between this check |
89 | | - * and the I/O. |
| 47 | + * among the path components from the managed root down to the target |
| 48 | + * (including in-root symlinks) so a mutation can never follow a symlink out |
| 49 | + * of, or around, the root. Only existing components are inspected, so it is |
| 50 | + * safe for not-yet-created upload/mkdir targets — call it again after |
| 51 | + * creating directories and right before the I/O. This closes deterministic, |
| 52 | + * pre-existing symlink escapes, not concurrent component-swap races. |
90 | 53 | */ |
91 | 54 | export async function assertAssetMutationPath(root: string, relativePath: string): Promise<string> { |
| 55 | + const lexRoot = resolve(root) |
92 | 56 | const absolute = resolveAssetPath(root, relativePath) |
93 | | - const canonRoot = await canonicalRoot(root) |
94 | | - const lexicalRoot = resolve(root) |
95 | | - const rel = absolute === lexicalRoot ? '' : absolute.slice(lexicalRoot.length + 1) |
96 | | - const segments = rel ? rel.split('/') : [] |
97 | | - |
98 | | - let current = canonRoot |
99 | | - for (const segment of segments) { |
100 | | - current = `${current}/${segment}` |
101 | | - let stat |
102 | | - try { |
103 | | - stat = await fsp.lstat(current) |
104 | | - } |
105 | | - catch { |
106 | | - // This component does not exist yet — nothing deeper can either, so |
107 | | - // there is no pre-existing symlink left to reject. |
| 57 | + let current = (await realpath(root)) ?? lexRoot |
| 58 | + for (const segment of absolute.slice(lexRoot.length).split('/').filter(Boolean)) { |
| 59 | + current += `/${segment}` |
| 60 | + const stat = await fsp.lstat(current).catch(() => null) |
| 61 | + if (!stat) |
108 | 62 | break |
109 | | - } |
110 | 63 | if (stat.isSymbolicLink()) |
111 | 64 | throw diagnostics.DP_ASSETS_0001({ path: relativePath }) |
112 | 65 | } |
|
0 commit comments