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

  1. Fix the source link/redirect to properly percent-encode the value (e.g. encodeURIComponent).
  2. Ensure upstream proxies pass the path through without corrupting escapes.
  3. 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

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


AI-assisted analysis of withastro/astro@d081033d5f (2026-08-12). Data as JSON: /api/errors/416fae3071779d8a. Report an issue: GitHub.