withastro/astro · error · MultiLevelEncodingError

URL encoding depth exceeded the maximum number of decode ite

Error message

URL encoding depth exceeded the maximum number of decode iterations

What it means

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.

Source

Thrown at packages/astro/src/core/util/pathname.ts:58

	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);
		} catch {
			// decodeURI throws when decoding leaves a real '%' next to
			// characters that look like broken encoding (for example '%?.pdf'
			// after decoding %25%3F). That's fine — we've decoded as far as we
			// can and the path won't change any further.
			break;
		}
		iterations++;
	}
	return decoded;
}

View on GitHub (pinned to 52e6c34790)

Solutions

  1. Encode exactly once, at the boundary that first builds the URL - remove extra encodeURIComponent/encodeURI layers.
  2. Audit middleware, proxies, and fetch wrappers between the client and Astro for re-encoding of already-encoded paths.
  3. If an external system legitimately forwards multiply-encoded paths, decode them fully before handing the path to Astro.
  4. If the producer cannot be changed, reject such requests at the edge (WAF/CDN rule) so they never reach routing.

Example fix

// before - value encoded twice
const url = `/docs/${encodeURIComponent(encodeURIComponent(path))}`;

// after - encode exactly once
const url = `/docs/${encodeURIComponent(path)}`;
Defensive patterns

Strategy: validation

Validate before calling

const MAX_HOPS = 10; // mirrors Astro's decode limit
function isReasonablyEncoded(pathname: string): boolean {
  let current = pathname;
  for (let i = 0; i < MAX_HOPS; i++) {
    let next: string;
    try {
      next = decodeURI(current);
    } catch {
      return true; // decoding stops here; nothing more will change
    }
    if (next === current) return true;
    current = next;
  }
  return false; // still changing after 10 passes - Astro will reject it
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of withastro/astro@52e6c34790 (2026-08-18). Data as JSON: /api/errors/3267cdee2daf976c. Report an issue: GitHub.