withastro/astro · error

Bad request.

Error message

Bad request.

What it means

The @astrojs/node standalone server calls `decodeURI(req.url)` before dispatching to validate the request path. `decodeURI` throws `URIError: URI malformed` when the URL contains an invalid percent-escape — a `%` not followed by two hex digits or a truncated multi-byte sequence — and the server converts that into `400 Bad request.`. This is almost always a client-side URL-building bug (a literal `%` that was never encoded), not a server fault.

Source

Thrown at packages/integrations/node/src/standalone.ts:62

		server,
		done: server.closed(),
	};
}

// also used by server entrypoint
export function createStandaloneHandler(
	app: BaseApp,
	options: Options,
	headersMap: NodeAppHeadersJson | undefined,
) {
	const appHandler = createAppHandler(app, options);
	const staticHandler = createStaticHandler(app, options, headersMap);
	return (req: http.IncomingMessage, res: http.ServerResponse) => {
		try {
			// validate request path
			decodeURI(req.url!);
		} catch {
			res.writeHead(400);
			res.end('Bad request.');
			return;
		}
		staticHandler(req, res, () => appHandler(req, res));
	};
}

// also used by preview entrypoint
export function createServer(listener: http.RequestListener, host: string, port: number) {
	let httpServer: http.Server | https.Server;

	if (process.env.SERVER_CERT_PATH && process.env.SERVER_KEY_PATH) {
		httpServer = https.createServer(
			{
				key: fs.readFileSync(process.env.SERVER_KEY_PATH),
				cert: fs.readFileSync(process.env.SERVER_CERT_PATH),
			},
			listener,

View on GitHub (pinned to 52e6c34790)

Solutions

  1. Find the offending URL: the access log line above shows the exact path with the bad escape
  2. Encode literal `%` as `%25` wherever links are generated (`/sale/100%25`)
  3. Build URLs with `encodeURIComponent` for dynamic segments or the `URL` class instead of concatenation
  4. If you must accept such paths, put a sanitizing middleware in front of the standalone handler

Example fix

// before: literal % breaks decodeURI -> 400 Bad request.
<a href={`/discount/${label}`}>50% off</a> // label = '50%'

// after: encode dynamic segments
<a href={`/discount/${encodeURIComponent(label)}`}>50% off</a>
Defensive patterns

Strategy: validation

Validate before calling

// Client-side: fix any href whose path contains an unescaped %
function safeHref(path: string): string {
  const u = new URL(path, location.origin);
  if (/%(?![0-9A-Fa-f]{2})/.test(u.pathname)) {
    return u.pathname.replace(/%(?![0-9A-Fa-f]{2})/g, '%25') + u.search;
  }
  return path;
}

Type guard

function isValidEncodedPath(pathname: string): boolean {
  try { decodeURI(pathname); return true; } catch { return false; }
}

Prevention

When it happens

Trigger: Requesting a path containing a raw percent like `/sale/100%`, an invalid escape like `/foo%zz`, or a truncated sequence like `/%E2%82`.

Common situations: Links built by string concatenation with unencoded `%`; user-typed URLs containing %; crawlers or log scanners hitting odd paths; migrating from another server that silently tolerated malformed percent-escapes.

Related errors


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