Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions packages/edge/src/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@ 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
* named `oxc masthead.riv` is stored under `/assets/oxc masthead.riv`, not
* `%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`,
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions platform/packages/dispatch/test/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<!DOCTYPE html><title>Fallback</title>', 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<typeof vi.fn>).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
Expand Down
60 changes: 57 additions & 3 deletions platform/packages/dispatch/test/unit/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -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`)
Expand Down
Loading