@@ -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,11 +31,39 @@ interface ResolvedFile {
3131
3232const HTML_EXTENSIONS = [ '.html' , '.htm' ]
3333
34- async function statFile ( abs : string ) : Promise < ResolvedFile | null > {
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+
39+ /**
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.
43+ */
44+ async function canonicalRoot ( absDir : string ) : Promise < string > {
45+ try {
46+ return normalize ( await realpath ( absDir ) )
47+ }
48+ catch {
49+ return absDir
50+ }
51+ }
52+
53+ /**
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.
58+ */
59+ async function statFile ( abs : string , realRoot : string ) : Promise < ResolvedFile | null > {
3560 try {
3661 const s = await stat ( abs )
3762 if ( ! s . isFile ( ) )
3863 return null
64+ const real = normalize ( await realpath ( abs ) )
65+ if ( ! isWithin ( real , realRoot ) )
66+ return null
3967 return { abs, size : s . size , mtime : s . mtime }
4068 }
4169 catch {
@@ -45,6 +73,7 @@ async function statFile(abs: string): Promise<ResolvedFile | null> {
4573
4674async function resolveTarget (
4775 absDir : string ,
76+ realRoot : string ,
4877 urlPath : string ,
4978 indexNames : string [ ] ,
5079 single : boolean ,
@@ -67,15 +96,15 @@ async function resolveTarget(
6796 if ( abs !== absDir && ! abs . startsWith ( absDir + sep ) )
6897 return null
6998
70- const direct = await statFile ( abs )
99+ const direct = await statFile ( abs , realRoot )
71100 if ( direct )
72101 return direct
73102
74103 try {
75104 const s = await stat ( abs )
76105 if ( s . isDirectory ( ) ) {
77106 for ( const name of indexNames ) {
78- const candidate = await statFile ( join ( abs , name ) )
107+ const candidate = await statFile ( join ( abs , name ) , realRoot )
79108 if ( candidate )
80109 return candidate
81110 }
@@ -90,15 +119,15 @@ async function resolveTarget(
90119 // fallback so pretty-URL deployments resolve to the right page.
91120 if ( ! extname ( cleaned ) ) {
92121 for ( const ext of HTML_EXTENSIONS ) {
93- const candidate = await statFile ( abs + ext )
122+ const candidate = await statFile ( abs + ext , realRoot )
94123 if ( candidate )
95124 return candidate
96125 }
97126 }
98127
99128 const fallbackIndex = indexNames [ 0 ]
100129 if ( single && fallbackIndex && ! / \. [ a - z 0 - 9 ] + $ / i. test ( cleaned ) ) {
101- const indexFile = await statFile ( join ( absDir , fallbackIndex ) )
130+ const indexFile = await statFile ( join ( absDir , fallbackIndex ) , realRoot )
102131 if ( indexFile )
103132 return indexFile
104133 }
@@ -199,14 +228,18 @@ export function serveStaticHandler(
199228 return serveRemoteAssetsHandler ( source )
200229 const absDir = resolve ( source )
201230 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 ) )
202235 return defineHandler ( async ( event ) => {
203236 const method = event . req . method
204237 if ( method !== 'GET' && method !== 'HEAD' ) {
205238 event . res . status = 405
206239 event . res . headers . set ( 'Allow' , 'GET, HEAD' )
207240 return ''
208241 }
209- const file = await resolveTarget ( absDir , event . url . pathname , opts . indexNames , opts . single )
242+ const file = await resolveTarget ( absDir , await getRealRoot ( ) , event . url . pathname , opts . indexNames , opts . single )
210243 if ( ! file ) {
211244 event . res . status = 404
212245 return ''
@@ -250,6 +283,8 @@ export function serveStaticNodeMiddleware(
250283) : ( req : IncomingMessage , res : ServerResponse , next ?: ( err ?: Error ) => void ) => void {
251284 const absDir = typeof source === 'string' ? resolve ( source ) : undefined
252285 const opts = normalizeOptions ( options )
286+ let realRootPromise : Promise < string > | undefined
287+ const getRealRoot = ( dir : string ) : Promise < string > => ( realRootPromise ??= canonicalRoot ( dir ) )
253288 return ( req , res , next ) => {
254289 void ( async ( ) => {
255290 const method = req . method
@@ -282,7 +317,7 @@ export function serveStaticNodeMiddleware(
282317 return
283318 }
284319
285- const file = await resolveTarget ( absDir , url , opts . indexNames , opts . single )
320+ const file = await resolveTarget ( absDir , await getRealRoot ( absDir ) , url , opts . indexNames , opts . single )
286321 if ( ! file ) {
287322 if ( next ) {
288323 next ( )
0 commit comments