@@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
33import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web'
44import type { RemoteAssetsErrorMessage , RemoteAssetsStore } from '../types/remote-assets'
55import { createReadStream } from 'node:fs'
6- import { stat } from 'node:fs/promises'
6+ import { realpath , stat } from 'node:fs/promises'
77import { Readable } from 'node:stream'
88import { defineHandler , H3 } from 'h3'
99import { lookup } from 'mrmime'
@@ -31,10 +31,25 @@ interface ResolvedFile {
3131
3232const 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
4661async 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 - z 0 - 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 ( )
0 commit comments