withastro/astro · error · Error

Invalid URL encoding

Error message

Invalid URL encoding

What it means

Astro fully decodes every request pathname so that middleware and routing always operate on the same real path. validateAndDecodePathname() throws this plain Error when the very first decodeURI() fails - the path contains percent-encoding that cannot be decoded at all, such as a lone `%` not followed by two hex digits. The request cannot be mapped to a real path safely, so it is rejected.

Solutions

  1. Find the offending URL in the request/error logs and fix the producer: encode a literal `%` as `%25` (encodeURIComponent('50%off') yields '50%25off').
  2. If the path originates from user input or an external system, validate or reject paths with broken escapes before they reach routing (edge rule or middleware that returns 400).
  3. Audit any proxy/CDN rewrite rules in front of Astro so already-encoded paths pass through unchanged.

Example fix

// before - raw value with a literal % ends up in the URL
<a href={`/shop/${coupon}`}> // coupon = '50%off' -> /shop/50%off

// after - encode dynamic path segments exactly once
<a href={`/shop/${encodeURIComponent(coupon)}`}> // /shop/50%25off
Defensive patterns

Strategy: validation

Validate before calling

function isDecodablePathname(pathname: string): boolean {
  try {
    decodeURI(pathname);
    return true;
  } catch {
    return false;
  }
}
// in middleware or an edge handler:
// if (!isDecodablePathname(url.pathname)) return new Response('Bad Request', { status: 400 });

Prevention

When it happens

Trigger: A request whose path contains a stray or truncated percent sign: /shop/50%off, /foo%.pdf, or /a%2 (incomplete escape); links built by concatenating raw user input into URLs without encodeURIComponent; crawlers and security scanners probing with malformed escapes.

Common situations: Marketing or affiliate links containing a literal percent (like '50% off') that were never encoded; a proxy or rewrite layer mangling paths and leaving a dangling `%`; fuzzing tools sending garbage percent sequences that surface as request failures in logs.

Related errors


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

Appendix: 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 52e6c34790)