withastro/astro · error · Error
Invalid URL encoding
Error message
Invalid URL encoding
What it means
validateAndDecodePathname decodes the request path so middleware always sees the real path. If the very first decodeURI throws (the path has broken percent-encoding, e.g. a lone '%' not followed by two hex digits), it throws a plain Error 'Invalid URL encoding'. Note: a separate MultiLevelEncodingError exists for paths encoded too many times (more than MAX_DECODE_ITERATIONS = 10).
Source
Thrown at packages/astro/src/core/util/pathname.ts:44
* encoded several times ends up as a single, final path. This stops someone
* from sneaking a path like `/admin` past middleware by encoding it multiple
* times — middleware always sees the real, decoded path.
*
* @param pathname - The path to decode
* @returns The final, fully decoded path
* @throws Error if the path has broken encoding that can't be decoded at all
* (for example a lone `%` that isn't followed by two hex digits)
* @throws MultiLevelEncodingError if the path is still changing after
* {@link MAX_DECODE_ITERATIONS} tries (it was encoded too many times).
* Handing back a half-decoded path here would bring back the security hole
* this function exists to close.
*/
export function validateAndDecodePathname(pathname: string): string {
let decoded: string;
try {
decoded = decodeURI(pathname);
} catch (_e) {
throw new Error('Invalid URL encoding');
}
// Keep decoding until the path stops changing. A path can be encoded more
// than once (for example %2561 → %61 → a), and we want the final decoded
// path so the rest of Astro — especially middleware security checks —
// always sees the same real path, no matter how many times it was encoded.
let iterations = 0;
while (decoded !== pathname) {
// The path is still changing after the maximum number of tries, so it
// was encoded too many times for us to fully decode. Stop and reject
// it: handing back a half-decoded path could let middleware check one
// path while a later decode (during rewrite routing) turns it into a
// different, possibly protected, path.
if (iterations >= MAX_DECODE_ITERATIONS) {
throw new MultiLevelEncodingError();
}
pathname = decoded;
try {
decoded = decodeURI(pathname);View on GitHub (pinned to d081033d5f)
Solutions
- Fix the source link/redirect to properly percent-encode the value (e.g. encodeURIComponent).
- Ensure upstream proxies pass the path through without corrupting escapes.
- Validate/normalize incoming URLs at the edge before they reach Astro.
Example fix
// before
<a href={`/search?q=${raw}`}>search</a> // raw may contain '%'
// after
<a href={`/search?q=${encodeURIComponent(raw)}`}>search</a> Defensive patterns
Strategy: try-catch
Validate before calling
function isDecodable(pathname) {
try { decodeURI(pathname); return true; } catch { return false; }
}
if (!isDecodable(req.url.pathname)) {
return new Response('Invalid URL encoding', { status: 400 });
} Type guard
const isDecodablePath = (p: string): boolean => {
try { decodeURI(p); return true; } catch { return false; }
}; Try / catch
try {
validateAndDecodePathname(pathname);
} catch (e) {
return new Response('Bad Request', { status: 400 });
} Prevention
- Always encodeURIComponent dynamic URL segments when building links.
- Validate/normalize paths at the edge or in middleware before routing.
- Reject malformed escapes early with a 400 instead of letting them reach routing.
When it happens
Trigger: An incoming URL containing a malformed percent-escape such as '/foo%zz', '/search?q=%', '/a%2', or '/bar%'. decodeURI cannot parse these and throws.
Common situations: Hand-built links with a stray '%'; a proxy/CDN rewriting URLs incorrectly; user input containing '%' passed unencoded into a link; double-processing that leaves a dangling escape.
Related errors
- MultiLevelEncodingError
- [RSS] You can only glob entries within 'src/pages/' when pas
- FontFileUrlNotFound
- RemoteImageNotAllowed
- UnsupportedImageFormat
AI-assisted analysis of withastro/astro@d081033d5f (2026-08-12).
Data as JSON: /api/errors/416fae3071779d8a.
Report an issue: GitHub.