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`)