Skip to content

Commit 7397fdd

Browse files
committed
fix(serve): ask the disk, not the extension, before dropping a catch-all
`getRoute` dropped every catch-all candidate whenever the request path carried a non-page extension. That guard is #1841 and it protects something real: `getRoute` runs before the publicDir handler, so an unguarded catch-all answers `/images/logo.jpg` with the 404 page before the actual file can be served. But the extension is a guess about intent, and it is the wrong question for an app whose catch-all legitimately serves paths with dots in them. A code browser is exactly that app - `/owner/repo/tree/main/src/index.ts` is a page, not an asset - and so is a docs site addressing `guide.md`. For those, the only route that could answer is the one being dropped, and the request falls through to a fallback that appends `.html`, so the view is handed a file name nobody asked for and reports it missing. Asking the disk keeps exactly what #1841 was protecting - a real `public/images/logo.jpg` still wins over `[...all].stx` - and costs one stat on a path that was about to be looked up anyway. `isStaticAssetPath` stays, because the implicit-`.html` retry further down is a different question with the same shape and still wants the cheap answer. `publicFileExists` normalizes traversal before the prefix check, the same way the publicDir handler does, so `..` cannot walk out of the root and report on a file that is none of the caller's business; it decodes escapes the way the server will, declines an embedded NUL, and is false for a directory, which is not a file to serve. All six are tested, along with the regression itself: an extensioned path publicDir does not have still resolves to the catch-all. The resolver test that mirrors `getRoute` now mirrors the new one - it stands the disk up as a list of paths rather than re-deriving the old extension rule, which would have kept passing while the server did something else.
1 parent 3497334 commit 7397fdd

2 files changed

Lines changed: 118 additions & 10 deletions

File tree

packages/bun-plugin/src/serve.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
*/
1515

1616
import { serve as bunServe, Glob } from 'bun'
17-
import { watch as fsWatch } from 'node:fs'
17+
import { existsSync, watch as fsWatch, statSync } from 'node:fs'
1818
import nodeFs from 'node:fs/promises'
1919
import nodePath from 'node:path'
2020
import process from 'node:process'
@@ -297,6 +297,50 @@ export function isStaticAssetPath(requestPath: string): boolean {
297297
return /\.[a-z0-9]+$/i.test(requestPath) && !/\.(?:stx|md|html)$/i.test(requestPath)
298298
}
299299

300+
/**
301+
* Does a file actually exist under `publicDir` for this request path?
302+
*
303+
* The extension test above is a guess about intent, and it is the wrong
304+
* question to ask before dropping a catch-all: an app whose catch-all
305+
* legitimately serves paths that carry extensions - a file browser, a docs
306+
* site addressing `guide.md`, anything rendering a repository - has every
307+
* such page refused, because the guess says "asset" and the only route that
308+
* could answer is the one being dropped.
309+
*
310+
* Asking the disk instead keeps what stacksjs/stx#1841 was protecting (a real
311+
* `public/images/logo.jpg` still wins over `[...all].stx`) and costs one stat
312+
* on a path that was going to be looked up moments later anyway.
313+
*
314+
* Traversal is normalized before the prefix check, the same way the publicDir
315+
* handler does it, so `..` cannot walk out of the root and report on a file
316+
* that is none of the caller's business.
317+
*/
318+
export function publicFileExists(requestPath: string, publicDir: string): boolean {
319+
let decoded: string
320+
try {
321+
decoded = decodeURIComponent(requestPath)
322+
}
323+
catch {
324+
decoded = requestPath
325+
}
326+
327+
if (decoded.includes('\0'))
328+
return false
329+
330+
const publicRoot = nodePath.resolve(process.cwd(), publicDir)
331+
const resolved = nodePath.resolve(publicRoot, `.${decoded}`)
332+
const inside = resolved === publicRoot || resolved.startsWith(`${publicRoot}${nodePath.sep}`)
333+
if (!inside)
334+
return false
335+
336+
try {
337+
return existsSync(resolved) && !statSync(resolved).isDirectory()
338+
}
339+
catch {
340+
return false
341+
}
342+
}
343+
300344
/**
301345
* Escape a string for safe interpolation into HTML text/attribute context.
302346
* Used so a crafted request path can't inject markup into the 404 page
@@ -2379,7 +2423,11 @@ function __stxOverlay(errs){
23792423
// the path carries a non-page file extension, drop catch-all candidates so
23802424
// the request falls through to publicDir (and then the real 404 page).
23812425
// Specific routes may still match (rare, but legitimate). stacksjs/stx#1841.
2382-
const isAssetRequest = isStaticAssetPath(normalizedPath)
2426+
// A catch-all is dropped when publicDir really holds this file, rather
2427+
// than whenever the path merely looks like an asset: the extension is a
2428+
// guess about intent, and it refuses every legitimate catch-all page
2429+
// whose path carries a dot (see publicFileExists).
2430+
const isAssetRequest = publicFileExists(`/${normalizedPath}`, publicDir)
23832431
const dynamicFiles = files
23842432
.filter((f) => {
23852433
const nf = f.replace(/^\.\//, '').replace(/\\/g, '/')

packages/bun-plugin/test/routes-catch-all.test.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { describe, expect, it } from 'bun:test'
2-
import { buildDynamicRouteRegexes, isStaticAssetPath, routeSpecificity } from '../src/serve'
1+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
2+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { buildDynamicRouteRegexes, isStaticAssetPath, publicFileExists, routeSpecificity } from '../src/serve'
35

46
/**
57
* The plugin's dev server compiles file routes itself. Catch-alls were turned
@@ -108,10 +110,11 @@ describe('isStaticAssetPath — catch-all never shadows a static asset (#1841)',
108110
expect(isStaticAssetPath(p)).toBe(false)
109111
})
110112

111-
// Mirrors getRoute: catch-all candidates are dropped for asset requests, so
112-
// the resolver returns null (→ publicDir serves the file, then the real 404).
113-
const resolveAsset = (target: string, files: string[]): string | null => {
114-
const asset = isStaticAssetPath(target)
113+
// Mirrors getRoute: catch-all candidates are dropped when publicDir really
114+
// holds the file, so the resolver returns null and publicDir serves it.
115+
// `exists` stands in for the disk.
116+
const resolveAsset = (target: string, files: string[], exists: string[] = []): string | null => {
117+
const asset = exists.includes(target)
115118
const candidates = files
116119
.filter(f => f.includes('[') && !(asset && /\[\.\.\./.test(f)))
117120
.sort((a, b) => routeSpecificity(b) - routeSpecificity(a))
@@ -124,9 +127,10 @@ describe('isStaticAssetPath — catch-all never shadows a static asset (#1841)',
124127
return null
125128
}
126129

127-
it('an image path does NOT resolve to the catch-all (falls through to publicDir)', () => {
130+
it('an image that publicDir has does NOT resolve to the catch-all', () => {
128131
const files = ['[...all].stx', 'foo/[id].stx']
129-
expect(resolveAsset('images/background-auth.jpg', files)).toBeNull()
132+
const target = 'images/background-auth.jpg'
133+
expect(resolveAsset(target, files, [target])).toBeNull()
130134
})
131135

132136
it('a real page miss still resolves to the catch-all', () => {
@@ -138,4 +142,60 @@ describe('isStaticAssetPath — catch-all never shadows a static asset (#1841)',
138142
const files = ['[...all].stx', 'download/[file].stx']
139143
expect(resolveAsset('download/report.pdf', files)).toBe('download/[file].stx')
140144
})
145+
146+
/*
147+
* The regression the disk check exists for. An app whose catch-all serves
148+
* paths that carry extensions - a code browser rendering
149+
* `/owner/repo/tree/main/src/index.ts` - had every such page refused,
150+
* because the extension test called it an asset and dropped the only route
151+
* that could answer. Nothing is at that path in publicDir, so nothing
152+
* should be dropped.
153+
*/
154+
it('an extensioned path publicDir does not have still resolves to the catch-all', () => {
155+
const files = ['[...all].stx', 'foo/[id].stx']
156+
expect(resolveAsset('owner/repo/tree/main/src/index.ts', files)).toBe('[...all].stx')
157+
})
158+
})
159+
160+
/**
161+
* The disk check itself. It answers for files that are really there, and
162+
* refuses to answer for anything outside the root however the path is spelled.
163+
*/
164+
describe('publicFileExists', () => {
165+
const root = 'test/fixtures/public-exists'
166+
167+
beforeAll(() => {
168+
mkdirSync(join(root, 'images'), { recursive: true })
169+
writeFileSync(join(root, 'images', 'logo.jpg'), 'not really a jpeg')
170+
})
171+
172+
afterAll(() => {
173+
rmSync(root, { recursive: true, force: true })
174+
})
175+
176+
it('finds a file that is there', () => {
177+
expect(publicFileExists('/images/logo.jpg', root)).toBe(true)
178+
})
179+
180+
it('does not find one that is not', () => {
181+
expect(publicFileExists('/images/missing.jpg', root)).toBe(false)
182+
expect(publicFileExists('/owner/repo/tree/main/src/index.ts', root)).toBe(false)
183+
})
184+
185+
it('is false for a directory, which is not a file to serve', () => {
186+
expect(publicFileExists('/images', root)).toBe(false)
187+
})
188+
189+
it('refuses to walk out of the root', () => {
190+
expect(publicFileExists('/../../package.json', root)).toBe(false)
191+
expect(publicFileExists('/images/../../../package.json', root)).toBe(false)
192+
})
193+
194+
it('reads an escaped path the same way the server will', () => {
195+
expect(publicFileExists('/images/%6Cogo.jpg', root)).toBe(true)
196+
})
197+
198+
it('declines a path carrying a NUL', () => {
199+
expect(publicFileExists('/images/logo.jpg%00.txt', root)).toBe(false)
200+
})
141201
})

0 commit comments

Comments
 (0)