Skip to content

Commit 5348f39

Browse files
committed
refactor: tighten symlink-containment helpers
1 parent 9f72642 commit 5348f39

3 files changed

Lines changed: 57 additions & 124 deletions

File tree

packages/devframe/src/utils/serve-static.ts

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,38 +31,25 @@ interface ResolvedFile {
3131

3232
const HTML_EXTENSIONS = ['.html', '.htm']
3333

34-
/** `child === root` or a path nested beneath it, using pathe's `/` separator. */
35-
function isWithin(child: string, root: string): boolean {
36-
return child === root || child.startsWith(root + sep)
37-
}
38-
3934
/**
40-
* The canonical (symlink-resolved) served root. Falls back to the lexical
41-
* path when the directory does not exist yet so an empty deployment simply
42-
* serves nothing rather than throwing.
35+
* The canonical (symlink-resolved) served root, falling back to the lexical
36+
* path when the directory doesn't exist yet (an empty deployment then serves
37+
* nothing rather than throwing).
4338
*/
4439
async function canonicalRoot(absDir: string): Promise<string> {
45-
try {
46-
return normalize(await realpath(absDir))
47-
}
48-
catch {
49-
return absDir
50-
}
40+
return realpath(absDir).then(normalize, () => absDir)
5141
}
5242

5343
/**
54-
* Stat a candidate file and confirm its canonical target stays inside the
55-
* canonical served root, so a symlink inside the root can only resolve to a
56-
* file that is still within the root. A symlink escaping the root reads as a
57-
* miss (`null`), not a leak.
44+
* Stat a candidate file, confirming its canonical target stays inside the
45+
* canonical served root — a symlink inside the root can only resolve to a
46+
* file still within it; one escaping the root reads as a miss, not a leak.
5847
*/
5948
async function statFile(abs: string, realRoot: string): Promise<ResolvedFile | null> {
6049
try {
6150
const s = await stat(abs)
62-
if (!s.isFile())
63-
return null
6451
const real = normalize(await realpath(abs))
65-
if (!isWithin(real, realRoot))
52+
if (!s.isFile() || (real !== realRoot && !real.startsWith(realRoot + sep)))
6653
return null
6754
return { abs, size: s.size, mtime: s.mtime }
6855
}
@@ -228,18 +215,17 @@ export function serveStaticHandler(
228215
return serveRemoteAssetsHandler(source)
229216
const absDir = resolve(source)
230217
const opts = normalizeOptions(options)
231-
// Canonicalize the served root once and reuse it — the containment check
232-
// compares every candidate's canonical path against this.
233-
let realRootPromise: Promise<string> | undefined
234-
const getRealRoot = (): Promise<string> => (realRootPromise ??= canonicalRoot(absDir))
218+
// Canonicalize the served root once; the containment check compares every
219+
// candidate's canonical path against it.
220+
const realRoot = canonicalRoot(absDir)
235221
return defineHandler(async (event) => {
236222
const method = event.req.method
237223
if (method !== 'GET' && method !== 'HEAD') {
238224
event.res.status = 405
239225
event.res.headers.set('Allow', 'GET, HEAD')
240226
return ''
241227
}
242-
const file = await resolveTarget(absDir, await getRealRoot(), event.url.pathname, opts.indexNames, opts.single)
228+
const file = await resolveTarget(absDir, await realRoot, event.url.pathname, opts.indexNames, opts.single)
243229
if (!file) {
244230
event.res.status = 404
245231
return ''
@@ -283,8 +269,7 @@ export function serveStaticNodeMiddleware(
283269
): (req: IncomingMessage, res: ServerResponse, next?: (err?: Error) => void) => void {
284270
const absDir = typeof source === 'string' ? resolve(source) : undefined
285271
const opts = normalizeOptions(options)
286-
let realRootPromise: Promise<string> | undefined
287-
const getRealRoot = (dir: string): Promise<string> => (realRootPromise ??= canonicalRoot(dir))
272+
const realRoot = absDir === undefined ? undefined : canonicalRoot(absDir)
288273
return (req, res, next) => {
289274
void (async () => {
290275
const method = req.method
@@ -317,7 +302,7 @@ export function serveStaticNodeMiddleware(
317302
return
318303
}
319304

320-
const file = await resolveTarget(absDir, await getRealRoot(absDir), url, opts.indexNames, opts.single)
305+
const file = await resolveTarget(absDir, await realRoot!, url, opts.indexNames, opts.single)
321306
if (!file) {
322307
if (next) {
323308
next()

plugins/assets/src/node/paths.ts

Lines changed: 34 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,65 @@
11
import fsp from 'node:fs/promises'
2-
import { dirname, normalize, resolve } from 'pathe'
2+
import { normalize, resolve } from 'pathe'
33
import { diagnostics } from '../diagnostics'
44

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> {
367
try {
37-
return normalize(await fsp.realpath(normalizedRoot))
8+
return normalize(await fsp.realpath(path))
389
}
3910
catch {
40-
return normalizedRoot
11+
return null
4112
}
4213
}
4314

4415
/**
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).
4721
*/
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
6128
}
6229

6330
/**
6431
* 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.
6835
*/
6936
export async function resolveAssetReadPath(root: string, relativePath: string): Promise<string> {
7037
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}/`))
7441
throw diagnostics.DP_ASSETS_0001({ path: relativePath })
7542
return absolute
7643
}
7744

7845
/**
7946
* 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.
9053
*/
9154
export async function assertAssetMutationPath(root: string, relativePath: string): Promise<string> {
55+
const lexRoot = resolve(root)
9256
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)
10862
break
109-
}
11063
if (stat.isSymbolicLink())
11164
throw diagnostics.DP_ASSETS_0001({ path: relativePath })
11265
}

services/open/src/index.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,28 +94,23 @@ export function createOpenService(options?: OpenServiceOptions): DevframeService
9494
// Option sets from multiple installers merge via devframe's default
9595
// deep-merge: `roots` union, `editor` last-wins.
9696
setup(ctx, { options }) {
97-
const allowedRoots = [ctx.workspaceRoot, ...(options?.roots ?? [])].map(root => resolve(root))
98-
99-
// Canonicalize the allowed roots once (resolving any symlink in the
100-
// root paths themselves) so containment compares canonical to
101-
// canonical.
102-
let canonicalRootsPromise: Promise<string[]> | undefined
103-
const canonicalRoots = (): Promise<string[]> => (canonicalRootsPromise ??= Promise.all(
104-
allowedRoots.map(async root => nearestExistingCanonical(root)),
105-
))
97+
// Canonicalize each allowed root once (resolving symlinks in the root
98+
// paths themselves) so containment compares canonical to canonical.
99+
const allowedRoots = Promise.all(
100+
[ctx.workspaceRoot, ...(options?.roots ?? [])].map(r => nearestExistingCanonical(resolve(r))),
101+
)
106102

107103
/**
108104
* Resolve `path` (relative paths against `workspaceRoot`) and assert
109105
* its canonical location lands inside one of the allowed roots, or
110-
* throw. Canonicalizing the nearest existing ancestor rejects a
111-
* symlink that would redirect the open outside every allowed root,
112-
* while still allowing not-yet-existing files under a root.
106+
* throw. Canonicalizing the nearest existing ancestor rejects a symlink
107+
* that would redirect the open outside every allowed root, while still
108+
* allowing not-yet-existing files under a root.
113109
*/
114110
async function assertAllowedPath(path: string): Promise<string> {
115111
const resolved = isAbsolute(path) ? resolve(path) : resolve(ctx.workspaceRoot, path)
116-
const roots = await canonicalRoots()
117112
const canonical = await nearestExistingCanonical(resolved)
118-
const contained = roots.some((root) => {
113+
const contained = (await allowedRoots).some((root) => {
119114
const rel = relative(root, canonical)
120115
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
121116
})

0 commit comments

Comments
 (0)