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
- Find the offending URL: the access log line above shows the exact path with the bad escape
- Encode literal `%` as `%25` wherever links are generated (`/sale/100%25`)
- Build URLs with `encodeURIComponent` for dynamic segments or the `URL` class instead of concatenation
- 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
- Always build dynamic URL segments with encodeURIComponent
- Prefer the URL class over string concatenation for links
- Never emit literal '%' in hrefs from templates — write %25
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.