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 */
108export 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 */
2939export 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 ++ ;
0 commit comments