Skip to content

Commit 27c80ea

Browse files
authored
fix(core): encoded URLs (#17109)
1 parent 910e121 commit 27c80ea

8 files changed

Lines changed: 187 additions & 32 deletions

File tree

.changeset/bright-insects-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Harden the limits on the number of encodings on the URL.

packages/astro/src/core/fetch/fetch-state.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { getParams, getProps } from '../render/index.js';
3535
import { Rewrites } from '../rewrites/handler.js';
3636
import { isRoute404or500, isRouteServerIsland } from '../routing/match.js';
3737
import { normalizeUrl } from '../util/normalized-url.js';
38-
import { validateAndDecodePathname } from '../util/pathname.js';
38+
import { MultiLevelEncodingError, validateAndDecodePathname } from '../util/pathname.js';
3939
import { getOriginPathname, setOriginPathname } from '../routing/rewrite.js';
4040
import { computePathnameFromDomain } from '../i18n/domain.js';
4141
import { getCustom404Route, routeHasHtmlExtension } from '../routing/helpers.js';
@@ -186,6 +186,12 @@ export class FetchState implements AstroFetchState {
186186
status = 200;
187187
/** Whether user middleware should be skipped for this request. */
188188
skipMiddleware = false;
189+
/**
190+
* Set to `true` when the request path was encoded too many times to fully
191+
* decode (see {@link validateAndDecodePathname}). These requests are
192+
* rejected with a `400` before middleware or routing run.
193+
*/
194+
invalidEncoding = false;
189195
/** A flag that tells the render content if the rewriting was triggered. */
190196
isRewriting = false;
191197
/** A safety net in case of loops (rewrite counter). */
@@ -902,6 +908,13 @@ export class FetchState implements AstroFetchState {
902908
try {
903909
return validateAndDecodePathname(pathname);
904910
} catch (e: any) {
911+
// The path was encoded too many times to fully decode. Mark it so
912+
// the handler can reject the request with a 400 before middleware
913+
// or routing run, instead of working with a half-decoded path.
914+
if (e instanceof MultiLevelEncodingError) {
915+
this.invalidEncoding = true;
916+
return pathname;
917+
}
905918
this.pipeline.logger.error(null, e.toString());
906919
return pathname;
907920
}

packages/astro/src/core/i18n/handler.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ export class I18n {
7777
return response;
7878
}
7979

80-
const url = new URL(state.request.url);
80+
// Use Astro's already-decoded URL (`state.url`) instead of reading the
81+
// raw request URL again, so locale checks use the same path as routing.
82+
const url = state.url;
8183
const currentLocale = state.computeCurrentLocale();
8284
const isPrerendered = state.routeData!.prerender;
8385

packages/astro/src/core/routing/handler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ export class AstroHandler {
7777
// forget to include anything.
7878
state.pipeline.usedFeatures |= ALL_PIPELINE_FEATURES;
7979

80+
// Reject paths that were encoded too many times to fully decode, before
81+
// any routing or middleware runs. If we let them through, middleware
82+
// could check one path while a later decode turns it into a different
83+
// route.
84+
if (state.invalidEncoding) {
85+
return new Response(null, { status: 400, statusText: 'Bad Request' });
86+
}
87+
8088
const trailingSlashRedirect = this.#trailingSlashHandler.handle(state);
8189
if (trailingSlashRedirect) {
8290
return trailingSlashRedirect;

packages/astro/src/core/routing/rewrite.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
trimSlashes,
1515
} from '../path.js';
1616
import { createRequest } from '../request.js';
17+
import { validateAndDecodePathname } from '../util/pathname.js';
1718
import { DEFAULT_404_ROUTE } from './internal/astro-designed-error-pages.js';
1819
import { isRoute404, isRoute500 } from './internal/route-errors.js';
1920

@@ -64,7 +65,13 @@ export function findRouteToRewrite({
6465
);
6566
newUrl.pathname = resolvedUrlPathname;
6667

67-
const decodedPathname = decodeURI(pathname);
68+
// Decode the path the same way the first route match did (see
69+
// `validateAndDecodePathname`) instead of calling `decodeURI` once here.
70+
// Routing has to use the exact same path that middleware checked; decoding
71+
// one extra time here is what let an encoded path slip past middleware and
72+
// still reach a protected route. For an already-decoded path this changes
73+
// nothing.
74+
const decodedPathname = validateAndDecodePathname(pathname);
6875

6976
// Error pages (404/500) take precedence over dynamic routes that might
7077
// capture the same path (e.g. [locale] matching /404). See #15098.

packages/astro/src/core/util/pathname.ts

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,40 @@
11
/**
2-
* Error thrown when multi-level URL encoding is detected in a pathname.
3-
* This is a distinct error type so callers can handle it specifically
4-
* (e.g., returning a 400 response) rather than falling back to partial decoding.
5-
*
6-
* @deprecated No longer thrown internally — multi-level encoding is now
7-
* decoded iteratively instead of rejected. Kept for backwards compatibility
8-
* in case third-party code references the class.
2+
* Thrown when a URL path is encoded so many times that we give up decoding it
3+
* (see {@link validateAndDecodePathname}). When this happens we reject the
4+
* request with a `400` instead of guessing the path. If we let a half-decoded
5+
* path through, your middleware might check one path while Astro routes to a
6+
* different one.
97
*/
108
export class MultiLevelEncodingError extends Error {
119
constructor() {
12-
super('Multi-level URL encoding is not allowed');
10+
super('URL encoding depth exceeded the maximum number of decode iterations');
1311
this.name = 'MultiLevelEncodingError';
1412
}
1513
}
1614

1715
/**
18-
* Decodes a pathname iteratively until stable, collapsing all levels of
19-
* percent-encoding into a single canonical form. This prevents
20-
* double/triple encoding from bypassing middleware authorization checks
21-
* (CVE-2025-66202) — instead of rejecting multi-level encoding, we
22-
* fully resolve it so middleware always sees the true decoded path.
16+
* How many times {@link validateAndDecodePathname} will decode a path before
17+
* giving up. A normal URL is encoded once, or at most twice — for example a
18+
* `[` (`%5B`) can arrive as `%255B` when a link is built from a value that was
19+
* already encoded. A path that is still encoded after this many tries is
20+
* almost certainly an attack, so we reject it instead of decoding again.
21+
*/
22+
const MAX_DECODE_ITERATIONS = 10;
23+
24+
/**
25+
* Decodes a URL path over and over until it stops changing, so a path that was
26+
* encoded several times ends up as a single, final path. This stops someone
27+
* from sneaking a path like `/admin` past middleware by encoding it multiple
28+
* times — middleware always sees the real, decoded path.
2329
*
24-
* @param pathname - The pathname to decode
25-
* @returns The fully decoded pathname
26-
* @throws Error if the pathname contains invalid URL encoding that
27-
* cannot be decoded at all (e.g., a bare `%` not followed by hex digits)
30+
* @param pathname - The path to decode
31+
* @returns The final, fully decoded path
32+
* @throws Error if the path has broken encoding that can't be decoded at all
33+
* (for example a lone `%` that isn't followed by two hex digits)
34+
* @throws MultiLevelEncodingError if the path is still changing after
35+
* {@link MAX_DECODE_ITERATIONS} tries (it was encoded too many times).
36+
* Handing back a half-decoded path here would bring back the security hole
37+
* this function exists to close.
2838
*/
2939
export function validateAndDecodePathname(pathname: string): string {
3040
let decoded: string;
@@ -33,21 +43,28 @@ export function validateAndDecodePathname(pathname: string): string {
3343
} catch (_e) {
3444
throw new Error('Invalid URL encoding');
3545
}
36-
// Iteratively decode until stable. Multi-level encoding (e.g.,
37-
// %2561 → %61 → a) is resolved completely so that downstream code
38-
// — especially middleware auth checks — always sees the canonical
39-
// pathname regardless of how many encoding layers the client used.
40-
// We cap iterations to prevent infinite loops on pathological input.
46+
// Keep decoding until the path stops changing. A path can be encoded more
47+
// than once (for example %2561 → %61 → a), and we want the final decoded
48+
// path so the rest of Astro — especially middleware security checks —
49+
// always sees the same real path, no matter how many times it was encoded.
4150
let iterations = 0;
42-
while (decoded !== pathname && iterations < 10) {
51+
while (decoded !== pathname) {
52+
// The path is still changing after the maximum number of tries, so it
53+
// was encoded too many times for us to fully decode. Stop and reject
54+
// it: handing back a half-decoded path could let middleware check one
55+
// path while a later decode (during rewrite routing) turns it into a
56+
// different, possibly protected, path.
57+
if (iterations >= MAX_DECODE_ITERATIONS) {
58+
throw new MultiLevelEncodingError();
59+
}
4360
pathname = decoded;
4461
try {
4562
decoded = decodeURI(pathname);
4663
} catch {
47-
// decodeURI can fail when a decoded literal '%' forms an
48-
// invalid sequence with adjacent characters (e.g., '%?.pdf'
49-
// after decoding %25%3F). This is fine — we've decoded as
50-
// far as possible.
64+
// decodeURI throws when decoding leaves a real '%' next to
65+
// characters that look like broken encoding (for example '%?.pdf'
66+
// after decoding %25%3F). That's fine — we've decoded as far as we
67+
// can and the path won't change any further.
5168
break;
5269
}
5370
iterations++;

packages/astro/test/units/app/double-encoding-bypass.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,26 @@ function createAuthMiddleware() {
7878
})) as () => Promise<{ onRequest: MiddlewareHandler }>;
7979
}
8080

81+
/**
82+
* Like {@link createAuthMiddleware}, but every allowed request is sent back
83+
* through routing again with `next(context.url)`. The route it lands on must
84+
* match the same path the `/api/admin` check looked at; otherwise an encoded
85+
* path can slip past the check and still be decoded into `/api/admin`.
86+
*/
87+
function createRewriteAuthMiddleware() {
88+
return (async () => ({
89+
onRequest: (async (context, next) => {
90+
if (context.url.pathname.startsWith('/api/admin')) {
91+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
92+
status: 401,
93+
headers: { 'Content-Type': 'application/json' },
94+
});
95+
}
96+
return next(context.url);
97+
}) satisfies MiddlewareHandler,
98+
})) as () => Promise<{ onRequest: MiddlewareHandler }>;
99+
}
100+
81101
function createApp(middleware: ReturnType<typeof createAuthMiddleware>) {
82102
return new App(
83103
createManifest({
@@ -233,3 +253,48 @@ describe('URL normalization: double-encoding middleware bypass', () => {
233253
});
234254
// #endregion
235255
});
256+
257+
describe('URL normalization: rewrite-based middleware bypass', () => {
258+
it('blocks /api/admin even when middleware calls next(context.url)', async () => {
259+
const app = createApp(createRewriteAuthMiddleware());
260+
const response = await app.render(new Request('http://example.com/api/admin'));
261+
assert.equal(response.status, 401, '/api/admin must be blocked by middleware');
262+
});
263+
264+
it('blocks double-encoded /api/%2561dmin with a rewriting middleware', async () => {
265+
// This decodes to /api/admin, so middleware must see /api/admin and
266+
// block it, even though the request is then sent back through routing.
267+
const app = createApp(createRewriteAuthMiddleware());
268+
const response = await app.render(new Request('http://example.com/api/%2561dmin'));
269+
assert.equal(
270+
response.status,
271+
401,
272+
'double-encoded /api/admin must be blocked even with a rewriting middleware',
273+
);
274+
});
275+
276+
it('rejects an over-encoded path with 400 instead of bypassing to /api/admin', async () => {
277+
// Encoded more times than we decode.
278+
const app = createApp(createRewriteAuthMiddleware());
279+
const response = await app.render(
280+
new Request('http://example.com/api/%2525252525252525252561dmin'),
281+
);
282+
assert.equal(response.status, 400, 'over-encoded path must be rejected, not served');
283+
});
284+
285+
it('rejects a deeply over-encoded payload with 400', async () => {
286+
const app = createApp(createRewriteAuthMiddleware());
287+
const response = await app.render(
288+
new Request('http://example.com/api/%252525252525252525252561dmin'),
289+
);
290+
assert.equal(response.status, 400);
291+
});
292+
293+
it('still serves non-protected routes through the rewriting middleware', async () => {
294+
const app = createApp(createRewriteAuthMiddleware());
295+
const response = await app.render(new Request('http://example.com/api/public/data'));
296+
assert.equal(response.status, 200, '/api/public/data should be accessible');
297+
const body = await response.json();
298+
assert.equal(body.path, 'public/data');
299+
});
300+
});

packages/astro/test/units/util/validate-and-decode-pathname.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import assert from 'node:assert/strict';
22
import { describe, it } from 'node:test';
3-
import { validateAndDecodePathname } from '../../../dist/core/util/pathname.js';
3+
import {
4+
MultiLevelEncodingError,
5+
validateAndDecodePathname,
6+
} from '../../../dist/core/util/pathname.js';
47

58
describe('validateAndDecodePathname', () => {
69
// #region Plain paths (no encoding)
@@ -33,7 +36,7 @@ describe('validateAndDecodePathname', () => {
3336
//
3437
// Multi-level encoding is decoded iteratively until stable. This
3538
// ensures middleware always sees the canonical path and can make
36-
// correct authorization decisions (CVE-2025-66202 mitigation).
39+
// correct authorization decisions.
3740

3841
it('fully decodes double-encoded unreserved chars: %2561 (a)', () => {
3942
// %2561 → decodeURI → %61 → decodeURI → a
@@ -187,4 +190,39 @@ describe('validateAndDecodePathname', () => {
187190
);
188191
});
189192
// #endregion
193+
// #region Encoded too many times (rejected, must throw)
194+
//
195+
// If a path is still encoded after the maximum number of decode tries, we
196+
// can't fully decode it. Returning the half-decoded path would let
197+
// middleware check one path while a later decode (during rewrite routing)
198+
// turns it into a different, protected path. So we reject these instead.
199+
200+
it('decodes a path encoded right up to the limit (10 times)', () => {
201+
// Encoded 10 times — the most we allow — still decodes fully.
202+
assert.equal(validateAndDecodePathname('/api/%25252525252525252561dmin'), '/api/admin');
203+
});
204+
205+
it('throws MultiLevelEncodingError once a path is encoded past the limit (11 times)', () => {
206+
assert.throws(
207+
() => validateAndDecodePathname('/api/%2525252525252525252561dmin'),
208+
(err: any) => {
209+
assert.equal(err instanceof MultiLevelEncodingError, true);
210+
return true;
211+
},
212+
'a path encoded past the limit must be rejected',
213+
);
214+
});
215+
216+
it('throws MultiLevelEncodingError for a path encoded many times', () => {
217+
// Before the fix, this decoded only part way to `/%61dmin` and slipped
218+
// past middleware.
219+
assert.throws(
220+
() => validateAndDecodePathname('/%252525252525252525252561dmin'),
221+
(err: any) => {
222+
assert.equal(err instanceof MultiLevelEncodingError, true);
223+
return true;
224+
},
225+
);
226+
});
227+
// #endregion
190228
});

0 commit comments

Comments
 (0)