{"record":{"id":"3267cdee2daf976c","repo":"withastro/astro","slug":"url-encoding-depth-exceeded-the-maximum-number-of","errorCode":null,"errorMessage":"URL encoding depth exceeded the maximum number of decode iterations","messagePattern":"URL encoding depth exceeded the maximum number of decode iterations","errorType":"exception","errorClass":"MultiLevelEncodingError","httpStatus":400,"severity":"error","filePath":"packages/astro/src/core/util/pathname.ts","lineNumber":58,"sourceCode":"\tlet decoded: string;\n\ttry {\n\t\tdecoded = decodeURI(pathname);\n\t} catch (_e) {\n\t\tthrow new Error('Invalid URL encoding');\n\t}\n\t// Keep decoding until the path stops changing. A path can be encoded more\n\t// than once (for example %2561 → %61 → a), and we want the final decoded\n\t// path so the rest of Astro — especially middleware security checks —\n\t// always sees the same real path, no matter how many times it was encoded.\n\tlet iterations = 0;\n\twhile (decoded !== pathname) {\n\t\t// The path is still changing after the maximum number of tries, so it\n\t\t// was encoded too many times for us to fully decode. Stop and reject\n\t\t// it: handing back a half-decoded path could let middleware check one\n\t\t// path while a later decode (during rewrite routing) turns it into a\n\t\t// different, possibly protected, path.\n\t\tif (iterations >= MAX_DECODE_ITERATIONS) {\n\t\t\tthrow new MultiLevelEncodingError();\n\t\t}\n\t\tpathname = decoded;\n\t\ttry {\n\t\t\tdecoded = decodeURI(pathname);\n\t\t} catch {\n\t\t\t// decodeURI throws when decoding leaves a real '%' next to\n\t\t\t// characters that look like broken encoding (for example '%?.pdf'\n\t\t\t// after decoding %25%3F). That's fine — we've decoded as far as we\n\t\t\t// can and the path won't change any further.\n\t\t\tbreak;\n\t\t}\n\t\titerations++;\n\t}\n\treturn decoded;\n}\n","sourceCodeStart":40,"sourceCodeEnd":74,"githubUrl":"https://github.com/withastro/astro/blob/52e6c34790cc8ac4e69e6135ace06049867e5c4a/packages/astro/src/core/util/pathname.ts#L40-L74","documentation":"validateAndDecodePathname() decodes a request pathname repeatedly until it stops changing (MAX_DECODE_ITERATIONS = 10), so middleware checks and routing always see the final, real path. MultiLevelEncodingError is thrown when the path is STILL changing after those 10 passes - it was encoded many times over (e.g. `%25252561`). Handing back a half-decoded path could let middleware authorize one path while a later decode turns it into a different, possibly protected, path, so the request is rejected with a 400 instead of guessing.","triggerScenarios":"A path encoded three or more times, e.g. `a` -> `%61` -> `%2561` -> `%252561`; middleware, a proxy, or a fetch wrapper that re-encodes an already-encoded path on every hop; crafted requests attempting to smuggle a path like /admin past middleware via layered encoding.","commonSituations":"Chained encodeURIComponent() calls applied to the same value on both client and server; API gateways or CDNs that normalize and re-encode path components; penetration tests reporting double/triple encoding as a path-traversal finding against the Astro app.","solutions":["Encode exactly once, at the boundary that first builds the URL - remove extra encodeURIComponent/encodeURI layers.","Audit middleware, proxies, and fetch wrappers between the client and Astro for re-encoding of already-encoded paths.","If an external system legitimately forwards multiply-encoded paths, decode them fully before handing the path to Astro.","If the producer cannot be changed, reject such requests at the edge (WAF/CDN rule) so they never reach routing."],"exampleFix":"// before - value encoded twice\nconst url = `/docs/${encodeURIComponent(encodeURIComponent(path))}`;\n\n// after - encode exactly once\nconst url = `/docs/${encodeURIComponent(path)}`;","handlingStrategy":"validation","validationCode":"const MAX_HOPS = 10; // mirrors Astro's decode limit\nfunction isReasonablyEncoded(pathname: string): boolean {\n  let current = pathname;\n  for (let i = 0; i < MAX_HOPS; i++) {\n    let next: string;\n    try {\n      next = decodeURI(current);\n    } catch {\n      return true; // decoding stops here; nothing more will change\n    }\n    if (next === current) return true;\n    current = next;\n  }\n  return false; // still changing after 10 passes - Astro will reject it\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Apply encodeURIComponent exactly once per URL segment.","Never re-encode an already-encoded value; decode it first if you must change it.","Audit proxies and fetch wrappers in the request chain for re-encoding of paths.","Include double-encoding probes in staging security scans so surprises surface before production."],"tags":["url-encoding","security","middleware","routing"],"backgroundTag":"double-url-encoding","analyzedSha":"52e6c34790cc8ac4e69e6135ace06049867e5c4a","analyzedAt":"2026-08-18T18:48:03.901Z","contentChangedAt":"2026-08-18T18:48:03.901Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}