From 41741680c215747a84224c9653678f72a3b0927d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 17 Oct 2026 01:02:12 +0800 Subject: [PATCH] fix(edge): resolve static assets with encoded square brackets --- packages/edge/src/static.ts | 16 ++--- .../packages/dispatch/test/unit/index.test.ts | 41 +++++++++++++ .../dispatch/test/unit/static.test.ts | 60 ++++++++++++++++++- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/packages/edge/src/static.ts b/packages/edge/src/static.ts index 5c9542da9..edbddef0e 100644 --- a/packages/edge/src/static.ts +++ b/packages/edge/src/static.ts @@ -11,10 +11,9 @@ export type ResolvedAsset = Readonly<{ }>; /** - * Percent-decode a request pathname for a manifest lookup, honoring ONLY the - * encodings a conformant URL is required to use. Returns the raw value - * unchanged on a malformed sequence (e.g. a lone `%`) or on any non-canonical / - * over-encoded spelling. + * Percent-decode a request pathname for a manifest lookup, honoring required + * URL encodings and encoded square brackets. Returns the raw value unchanged + * on a malformed sequence (e.g. a lone `%`) or other over-encoded spellings. * * Request pathnames arrive percent-encoded — WHATWG `URL.pathname` never * decodes — but manifest keys are the asset's decoded on-disk path (a file @@ -22,8 +21,10 @@ export type ResolvedAsset = Readonly<{ * `%20`). Callers try the raw key first, then this decoded form. * * The decoded form is accepted only when re-encoding it reproduces the request - * path (up to percent-escape letter case) — i.e. the only encoded bytes were - * characters a URL MUST encode (space, `#`, non-ASCII, …). This rejects + * path (up to percent-escape letter case and square-bracket encoding). Next.js + * encodes brackets in dynamic-page chunk URLs even though URL.pathname leaves + * them literal. Other accepted escapes are characters a URL MUST encode + * (space, `#`, non-ASCII, …). This rejects * over-encoded spellings of ordinary path text (`%2F`→`/`, `%61`→`a`, `%5F`→`_`, * `%2e%2e`→`..`), which a browser would never send and which would otherwise let * a request reconstruct a guarded path (`/assets%2Fx`, `/%61ssets/x`, @@ -53,7 +54,8 @@ export function safeDecodePathname(pathname: string): string { } // Compare up to percent-escape letter case: the URL encoder emits upper-case // (`%C3`), but a client may send lower-case (`%c3`), and both are valid. - return normalizePercentCase(canonical) === normalizePercentCase(pathname) ? decoded : pathname; + const normalized = normalizePercentCase(pathname).replace(/%5B/g, '[').replace(/%5D/g, ']'); + return normalizePercentCase(canonical) === normalized ? decoded : pathname; } /** Uppercase the hex digits of every `%xx` escape so encodings compare equal diff --git a/platform/packages/dispatch/test/unit/index.test.ts b/platform/packages/dispatch/test/unit/index.test.ts index d0f6b9ed0..aa162a4be 100644 --- a/platform/packages/dispatch/test/unit/index.test.ts +++ b/platform/packages/dispatch/test/unit/index.test.ts @@ -6376,6 +6376,47 @@ describe('static deployment caching (KV/R2)', () => { } }); + it.each([undefined, '_next/static'])( + 'serves encoded Next.js chunks before HTML fallback with hashedAssetsPrefix=%s', + async (hashedAssetsPrefix) => { + const path = '/_next/static/chunks/pages/package/[...slug]-abc123.js'; + const body = 'self.webpackChunk_N_E = [];'; + const { env } = createStaticDeployEnv({ + routing: { + workerName: '', + projectId: 'proj-1', + staticDeploymentId: 'deploy-abc', + version: 'deploy-abc', + hashedAssetsPrefix, + fallbackRules: [{ source: '/*', destination: '/index.html', status: 200 }], + }, + manifest: { [path]: 'hash_chunk', '/index.html': 'hash_index' }, + assets: { + hash_chunk: { body, contentType: 'text/javascript; charset=utf-8' }, + hash_index: { body: 'Fallback', contentType: 'text/html' }, + }, + }); + + for (const pathname of [path, path.replace('[', '%5B').replace(']', '%5D')]) { + // Check both the initial storage response and the cached response. + for (let attempt = 0; attempt < 2; attempt++) { + const ctx = createCtx(); + const response = await app.fetch( + new Request(`https://test.void.app${pathname}`), + env, + ctx, + ); + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('text/javascript; charset=utf-8'); + expect(await response.text()).toBe(body); + await Promise.all( + (ctx.waitUntil as ReturnType).mock.calls.map((c) => c[0]), + ); + } + } + }, + ); + it('serves a hashed asset whose filename contains a space (%20-encoded request)', async () => { // Regression: the oxc.rs hero `.riv` 404'd. The manifest key is the decoded // on-disk path (literal space), but the browser requests it `%20`-encoded and diff --git a/platform/packages/dispatch/test/unit/static.test.ts b/platform/packages/dispatch/test/unit/static.test.ts index 4c3d4ce5b..035c713c4 100644 --- a/platform/packages/dispatch/test/unit/static.test.ts +++ b/platform/packages/dispatch/test/unit/static.test.ts @@ -189,6 +189,60 @@ describe('resolveStaticPath', () => { }); }); + it.each(['[...slug]', '%5B...slug%5D', '%5b...slug%5d', '[...slug%5D', '%5B...slug]'])( + 'resolves a Next.js chunk with square brackets requested as %s before the SPA fallback', + (filename) => { + const path = '/_next/static/chunks/pages/package/[...slug]-abc123.js'; + expect( + resolveStaticPath( + `/_next/static/chunks/pages/package/${filename}-abc123.js`, + { [path]: 'hash_chunk', '/index.html': 'hash_index' }, + { html_handling: 'none', not_found_handling: 'single-page-application' }, + ), + ).toEqual({ path, hash: 'hash_chunk' }); + }, + ); + + it('resolves encoded brackets with spaces and UTF-8 in directory and file names', () => { + const path = '/files/[café docs]/[[...slug]].js'; + expect( + resolveStaticPath( + '/files/%5Bcaf%C3%A9%20docs%5D/%5B%5B...slug%5D%5D.js', + { [path]: 'hash_chunk' }, + { html_handling: 'none' }, + ), + ).toEqual({ path, hash: 'hash_chunk' }); + }); + + it('prefers a literal percent-encoded bracket filename over the decoded key', () => { + const path = '/files/%5Bslug%5D.js'; + expect( + resolveStaticPath( + path, + { [path]: 'raw_hit', '/files/[slug].js': 'decoded_hit' }, + { html_handling: 'none' }, + ), + ).toEqual({ path, hash: 'raw_hit' }); + }); + + it.each([ + '/assets%2F%5Bslug%5D.js', + '/%61ssets/%5Bslug%5D.js', + '/%5F%5Fvoid/%5Bslug%5D.js', + '/assets/%2e%2e/%5Bslug%5D.js', + '/assets/%255Bslug%255D.js', + '/assets/%5Bslug%5D%2.js', + ])('does not relax other encoding guards for bracketed paths: %s', (path) => { + const guarded = { + '/assets/[slug].js': 'hash_asset', + '/__void/[slug].js': 'hash_internal', + '/assets/../[slug].js': 'hash_dot', + '/assets/%5Bslug%5D.js': 'hash_percent', + '/assets/[slug]%2.js': 'hash_malformed', + }; + expect(resolveStaticPath(path, guarded, { html_handling: 'none' })).toBeNull(); + }); + it('still prefers an exact raw match over the decoded key', () => { // A file whose name literally contains "%20" must win on the raw path. const literal: Record = { @@ -205,9 +259,9 @@ describe('resolveStaticPath', () => { expect(resolveStaticPath('/bad%2', m, { html_handling: 'none' })).toBeNull(); }); - it('only honors decodings a conformant URL must encode (canonical-encoding guard)', () => { - // The decoded fallback must accept ONLY percent-encodings that a conformant - // URL is required to use (space, #, UTF-8, …). An over-encoded spelling of + it('rejects encoded separators, ordinary path text, and dot segments', () => { + // Apart from square brackets, the decoded fallback only accepts encodings + // a URL requires (space, #, UTF-8, …). An over-encoded spelling of // ordinary path text — %2F for '/', %61 for 'a', %5F for '_', %2e%2e for // '..' — must be rejected: otherwise a request could reconstruct a guarded // path (`/assets%2Fapp.js`, `/%61ssets/app.js`, `/%5F%5Fvoid/secret.js`)