Skip to content

Commit 79b8760

Browse files
authored
fix: enforce symlink-aware filesystem roots (#326)
1 parent 1eba745 commit 79b8760

15 files changed

Lines changed: 327 additions & 38 deletions

File tree

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { AddressInfo } from 'node:net'
22
import type { ServeStaticOptions } from './serve-static'
3-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3+
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'
44
import { createServer } from 'node:http'
55
import { tmpdir } from 'node:os'
66
import { join } from 'node:path'
7+
import process from 'node:process'
78
import { H3, toNodeHandler } from 'h3'
89
import { afterEach, describe, expect, it } from 'vitest'
910
import { mountStaticHandler, serveStaticHandler, serveStaticNodeMiddleware } from './serve-static'
@@ -216,6 +217,66 @@ describe('mountStaticHandler', () => {
216217
})
217218
})
218219

220+
// Symlinks require privileges on Windows that hosted CI runners lack, so
221+
// gate the symlink-containment suite off that platform.
222+
describe.skipIf(process.platform === 'win32')('serveStaticHandler symlink containment', () => {
223+
let fx: Fixture | undefined
224+
225+
afterEach(async () => {
226+
await fx?.close()
227+
fx = undefined
228+
})
229+
230+
it('returns 404 for a file symlink escaping the served root', async () => {
231+
const dir = makeTmp('devframe-serve-link-')
232+
const outside = makeTmp('devframe-serve-outside-')
233+
writeFileSync(join(outside, 'secret.txt'), 'top secret', 'utf-8')
234+
symlinkSync(join(outside, 'secret.txt'), join(dir, 'leak.txt'))
235+
writeFileSync(join(dir, 'ok.txt'), 'in root', 'utf-8')
236+
fx = await startH3(dir, { single: false })
237+
238+
const leak = await fetch(`${fx.baseUrl}/leak.txt`)
239+
expect(leak.status).toBe(404)
240+
// Ordinary in-root files still serve.
241+
const ok = await fetch(`${fx.baseUrl}/ok.txt`)
242+
expect(ok.status).toBe(200)
243+
expect(await ok.text()).toBe('in root')
244+
})
245+
246+
it('returns 404 for a file reached through an escaping directory symlink', async () => {
247+
const dir = makeTmp('devframe-serve-link-')
248+
const outside = makeTmp('devframe-serve-outside-')
249+
writeFileSync(join(outside, 'secret.txt'), 'top secret', 'utf-8')
250+
symlinkSync(outside, join(dir, 'escape'))
251+
fx = await startH3(dir, { single: false })
252+
253+
const res = await fetch(`${fx.baseUrl}/escape/secret.txt`)
254+
expect(res.status).toBe(404)
255+
})
256+
257+
it('serves a symlink whose canonical target stays inside the served root', async () => {
258+
const dir = makeTmp('devframe-serve-link-')
259+
writeFileSync(join(dir, 'real.txt'), 'contained', 'utf-8')
260+
symlinkSync(join(dir, 'real.txt'), join(dir, 'alias.txt'))
261+
fx = await startH3(dir, { single: false })
262+
263+
const res = await fetch(`${fx.baseUrl}/alias.txt`)
264+
expect(res.status).toBe(200)
265+
expect(await res.text()).toBe('contained')
266+
})
267+
268+
it('returns 404 through the Node middleware for an escaping symlink', async () => {
269+
const dir = makeTmp('devframe-serve-link-')
270+
const outside = makeTmp('devframe-serve-outside-')
271+
writeFileSync(join(outside, 'secret.txt'), 'top secret', 'utf-8')
272+
symlinkSync(join(outside, 'secret.txt'), join(dir, 'leak.txt'))
273+
fx = await startMw(dir, { single: false })
274+
275+
const res = await fetch(`${fx.baseUrl}/leak.txt`)
276+
expect(res.status).toBe(404)
277+
})
278+
})
279+
219280
describe('serveStaticNodeMiddleware', () => {
220281
let fx: Fixture | undefined
221282

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
33
import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web'
44
import type { RemoteAssetsErrorMessage, RemoteAssetsStore } from '../types/remote-assets'
55
import { createReadStream } from 'node:fs'
6-
import { stat } from 'node:fs/promises'
6+
import { realpath, stat } from 'node:fs/promises'
77
import { Readable } from 'node:stream'
88
import { defineHandler, H3 } from 'h3'
99
import { lookup } from 'mrmime'
@@ -31,10 +31,25 @@ interface ResolvedFile {
3131

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

34-
async function statFile(abs: string): Promise<ResolvedFile | null> {
34+
/**
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).
38+
*/
39+
async function canonicalRoot(absDir: string): Promise<string> {
40+
return realpath(absDir).then(normalize, () => absDir)
41+
}
42+
43+
/**
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.
47+
*/
48+
async function statFile(abs: string, realRoot: string): Promise<ResolvedFile | null> {
3549
try {
3650
const s = await stat(abs)
37-
if (!s.isFile())
51+
const real = normalize(await realpath(abs))
52+
if (!s.isFile() || (real !== realRoot && !real.startsWith(realRoot + sep)))
3853
return null
3954
return { abs, size: s.size, mtime: s.mtime }
4055
}
@@ -45,6 +60,7 @@ async function statFile(abs: string): Promise<ResolvedFile | null> {
4560

4661
async function resolveTarget(
4762
absDir: string,
63+
realRoot: string,
4864
urlPath: string,
4965
indexNames: string[],
5066
single: boolean,
@@ -67,15 +83,15 @@ async function resolveTarget(
6783
if (abs !== absDir && !abs.startsWith(absDir + sep))
6884
return null
6985

70-
const direct = await statFile(abs)
86+
const direct = await statFile(abs, realRoot)
7187
if (direct)
7288
return direct
7389

7490
try {
7591
const s = await stat(abs)
7692
if (s.isDirectory()) {
7793
for (const name of indexNames) {
78-
const candidate = await statFile(join(abs, name))
94+
const candidate = await statFile(join(abs, name), realRoot)
7995
if (candidate)
8096
return candidate
8197
}
@@ -90,15 +106,15 @@ async function resolveTarget(
90106
// fallback so pretty-URL deployments resolve to the right page.
91107
if (!extname(cleaned)) {
92108
for (const ext of HTML_EXTENSIONS) {
93-
const candidate = await statFile(abs + ext)
109+
const candidate = await statFile(abs + ext, realRoot)
94110
if (candidate)
95111
return candidate
96112
}
97113
}
98114

99115
const fallbackIndex = indexNames[0]
100116
if (single && fallbackIndex && !/\.[a-z0-9]+$/i.test(cleaned)) {
101-
const indexFile = await statFile(join(absDir, fallbackIndex))
117+
const indexFile = await statFile(join(absDir, fallbackIndex), realRoot)
102118
if (indexFile)
103119
return indexFile
104120
}
@@ -199,14 +215,17 @@ export function serveStaticHandler(
199215
return serveRemoteAssetsHandler(source)
200216
const absDir = resolve(source)
201217
const opts = normalizeOptions(options)
218+
// Canonicalize the served root once; the containment check compares every
219+
// candidate's canonical path against it.
220+
const realRoot = canonicalRoot(absDir)
202221
return defineHandler(async (event) => {
203222
const method = event.req.method
204223
if (method !== 'GET' && method !== 'HEAD') {
205224
event.res.status = 405
206225
event.res.headers.set('Allow', 'GET, HEAD')
207226
return ''
208227
}
209-
const file = await resolveTarget(absDir, event.url.pathname, opts.indexNames, opts.single)
228+
const file = await resolveTarget(absDir, await realRoot, event.url.pathname, opts.indexNames, opts.single)
210229
if (!file) {
211230
event.res.status = 404
212231
return ''
@@ -250,6 +269,7 @@ export function serveStaticNodeMiddleware(
250269
): (req: IncomingMessage, res: ServerResponse, next?: (err?: Error) => void) => void {
251270
const absDir = typeof source === 'string' ? resolve(source) : undefined
252271
const opts = normalizeOptions(options)
272+
const realRoot = absDir === undefined ? undefined : canonicalRoot(absDir)
253273
return (req, res, next) => {
254274
void (async () => {
255275
const method = req.method
@@ -282,7 +302,7 @@ export function serveStaticNodeMiddleware(
282302
return
283303
}
284304

285-
const file = await resolveTarget(absDir, url, opts.indexNames, opts.single)
305+
const file = await resolveTarget(absDir, await realRoot!, url, opts.indexNames, opts.single)
286306
if (!file) {
287307
if (next) {
288308
next()

‎plans/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Generated by the improve skill on 2026-09-01 at commit `2d978f84`. Execute in th
1212
| 004 | Contain remote asset materialization | P1 | S | - | DONE |
1313
| 005 | Block Data Inspector prototype-chain writes | P1 | S | - | DONE |
1414
| 006 | Validate request-derived authentication-link origins | P1 | M | - | TODO |
15-
| 007 | Reject pre-existing symlink escapes from filesystem roots | P2 | M | - | TODO |
15+
| 007 | Reject pre-existing symlink escapes from filesystem roots | P2 | M | - | DONE |
1616

1717
Status values: TODO | IN PROGRESS | DONE | BLOCKED (with reason) | REJECTED (with rationale)
1818

‎plugins/assets/src/node/context.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ export interface AssetsConfig {
1919
}
2020

2121
export interface AssetsContext extends AssetsConfig {
22-
/** Resolve a root-relative path to an absolute one, rejecting escapes. */
22+
/**
23+
* Resolve a root-relative path to an absolute one, rejecting lexical
24+
* escapes. Symlink-aware containment for reads and mutations lives in
25+
* `node/paths` (`resolveAssetReadPath` / `assertAssetMutationPath`), which
26+
* the RPC handlers call directly with {@link AssetsContext.dir}.
27+
*/
2328
resolvePath: (relativePath: string) => string
2429
}
2530

‎plugins/assets/src/node/paths.ts‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,67 @@
1-
import { resolve } from 'pathe'
1+
import fsp from 'node:fs/promises'
2+
import { normalize, resolve } from 'pathe'
23
import { diagnostics } from '../diagnostics'
34

5+
/** realpath, pathe-normalized, or `null` when the path doesn't exist. */
6+
async function realpath(path: string): Promise<string | null> {
7+
try {
8+
return normalize(await fsp.realpath(path))
9+
}
10+
catch {
11+
return null
12+
}
13+
}
14+
415
/**
516
* Resolve a client-supplied, root-relative path against the managed
6-
* directory, rejecting anything that would escape it (`..` traversal, a
7-
* rogue absolute path, etc.). Every RPC handler that touches the
8-
* filesystem goes through this — never trust a path from the wire.
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).
921
*/
1022
export function resolveAssetPath(root: string, relativePath: string): string {
11-
const cleaned = relativePath.replace(/^[/\\]+/, '')
1223
const normalizedRoot = resolve(root)
13-
const absolute = resolve(normalizedRoot, cleaned)
24+
const absolute = resolve(normalizedRoot, relativePath.replace(/^[/\\]+/, ''))
1425
if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}/`))
1526
throw diagnostics.DP_ASSETS_0001({ path: relativePath })
1627
return absolute
1728
}
29+
30+
/**
31+
* Resolve a path for a **read**, allowing a symlink only when its canonical
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.
35+
*/
36+
export async function resolveAssetReadPath(root: string, relativePath: string): Promise<string> {
37+
const absolute = resolveAssetPath(root, relativePath)
38+
const real = await realpath(absolute)
39+
const canonRoot = (await realpath(root)) ?? resolve(root)
40+
if (real && real !== canonRoot && !real.startsWith(`${canonRoot}/`))
41+
throw diagnostics.DP_ASSETS_0001({ path: relativePath })
42+
return absolute
43+
}
44+
45+
/**
46+
* Resolve a path for a **mutation**, rejecting every pre-existing symlink
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.
53+
*/
54+
export async function assertAssetMutationPath(root: string, relativePath: string): Promise<string> {
55+
const lexRoot = resolve(root)
56+
const absolute = resolveAssetPath(root, relativePath)
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)
62+
break
63+
if (stat.isSymbolicLink())
64+
throw diagnostics.DP_ASSETS_0001({ path: relativePath })
65+
}
66+
return absolute
67+
}

‎plugins/assets/src/node/scanner.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,17 @@ export function statToAssetInfo(dir: string, baseURL: string, relPath: string, s
5050

5151
/** Recursively lists every file under `dir`, sorted alphabetically by path. */
5252
export async function scanAssets(dir: string, baseURL: string, includeFsPath = false): Promise<AssetInfo[]> {
53-
const files = await glob(['**/*'], { cwd: dir, onlyFiles: true, dot: false })
53+
// Never traverse into or across symlinks — a symlink inside the managed
54+
// directory must not expose files (or whole trees) that live outside it.
55+
const files = await glob(['**/*'], { cwd: dir, onlyFiles: true, dot: false, followSymbolicLinks: false })
5456

5557
const infos = await Promise.all(files.map(async (relPath): Promise<AssetInfo | undefined> => {
5658
try {
5759
const stat = await fsp.lstat(join(dir, relPath))
60+
// `lstat` describes the link itself; drop any symlink entry so the
61+
// listing only ever names real files contained in the root.
62+
if (stat.isSymbolicLink())
63+
return undefined
5864
return statToAssetInfo(dir, baseURL, relPath, stat, includeFsPath)
5965
}
6066
catch {

‎plugins/assets/src/rpc/functions/delete.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fsp from 'node:fs/promises'
33
import { createDefineWrapperWithContext } from 'devframe/rpc'
44
import { s } from 'devframe/utils/simple-schema'
55
import { getAssetsContext } from '../../node/context'
6+
import { assertAssetMutationPath } from '../../node/paths'
67

78
const defineAssetsRpc = createDefineWrapperWithContext<DevframeNodeContext>()
89

@@ -26,7 +27,8 @@ export const deleteAssets = defineAssetsRpc({
2627
handler: (async ({ paths }: { paths: string[] }): Promise<{ deleted: string[] }> => {
2728
const deleted: string[] = []
2829
for (const path of paths) {
29-
const absolute = assets.resolvePath(path)
30+
// Reject any pre-existing symlink component right before unlinking.
31+
const absolute = await assertAssetMutationPath(assets.dir, path)
3032
try {
3133
await fsp.unlink(absolute)
3234
deleted.push(path)

‎plugins/assets/src/rpc/functions/mkdir.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createDefineWrapperWithContext } from 'devframe/rpc'
44
import { s } from 'devframe/utils/simple-schema'
55
import { diagnostics } from '../../diagnostics'
66
import { getAssetsContext } from '../../node/context'
7+
import { assertAssetMutationPath } from '../../node/paths'
78

89
const defineAssetsRpc = createDefineWrapperWithContext<DevframeNodeContext>()
910

@@ -24,11 +25,14 @@ export const mkdir = defineAssetsRpc({
2425
return {
2526
// See `list.ts` for why the async handler is cast.
2627
handler: (async ({ path }: { path: string }): Promise<void> => {
27-
const absolute = assets.resolvePath(path)
28+
const absolute = await assertAssetMutationPath(assets.dir, path)
2829
const stat = await fsp.stat(absolute).catch(() => undefined)
2930
if (stat && !stat.isDirectory())
3031
throw diagnostics.DP_ASSETS_0005({ path })
3132
await fsp.mkdir(absolute, { recursive: true })
33+
// Re-check after creation: reject any symlink component that
34+
// materialized under the root before anything follows this path.
35+
await assertAssetMutationPath(assets.dir, path)
3236
}) as any,
3337
}
3438
},

‎plugins/assets/src/rpc/functions/read-image-meta.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createDefineWrapperWithContext } from 'devframe/rpc'
55
import { s } from 'devframe/utils/simple-schema'
66
import { imageMeta } from 'image-meta'
77
import { getAssetsContext } from '../../node/context'
8+
import { resolveAssetReadPath } from '../../node/paths'
89

910
const defineAssetsRpc = createDefineWrapperWithContext<DevframeNodeContext>()
1011

@@ -30,7 +31,7 @@ export const readImageMeta = defineAssetsRpc({
3031
// See `list.ts` for why the async handler is cast.
3132
handler: (async (path: string): Promise<AssetImageMeta | null> => {
3233
try {
33-
const buffer = await fsp.readFile(assets.resolvePath(path))
34+
const buffer = await fsp.readFile(await resolveAssetReadPath(assets.dir, path))
3435
const meta = imageMeta(buffer)
3536
return { width: meta.width, height: meta.height, orientation: meta.orientation }
3637
}

‎plugins/assets/src/rpc/functions/read-text.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fsp from 'node:fs/promises'
33
import { createDefineWrapperWithContext } from 'devframe/rpc'
44
import { s } from 'devframe/utils/simple-schema'
55
import { getAssetsContext } from '../../node/context'
6+
import { resolveAssetReadPath } from '../../node/paths'
67

78
const defineAssetsRpc = createDefineWrapperWithContext<DevframeNodeContext>()
89

@@ -26,7 +27,7 @@ export const readText = defineAssetsRpc({
2627
// See `list.ts` for why the async handler is cast.
2728
handler: (async (path: string, limit: number = DEFAULT_LIMIT): Promise<string | null> => {
2829
try {
29-
const content = await fsp.readFile(assets.resolvePath(path), 'utf-8')
30+
const content = await fsp.readFile(await resolveAssetReadPath(assets.dir, path), 'utf-8')
3031
return content.slice(0, limit)
3132
}
3233
catch {

0 commit comments

Comments
 (0)