Skip to content

Commit 1ee75c7

Browse files
server: inject CORS headers on Fastify-internal errors (JavaScriptSolidServer#376) (JavaScriptSolidServer#421)
* server: inject CORS headers on Fastify-internal errors (JavaScriptSolidServer#376) Surfaced during JavaScriptSolidServer#374 review. When Fastify itself rejects a request before any handler runs (most commonly FST_ERR_BAD_URL for malformed percent-encoding like `%g1`, truncated `%E0%`, invalid UTF-8 sequences), the 400 response carries no `Access-Control-Allow-*` headers — browsers surface it as a generic CORS / network error instead of the real status, which is confusing for debugging and undermines the per-handler CORS work in JavaScriptSolidServer#371 / JavaScriptSolidServer#374. ## Root cause `FST_ERR_BAD_URL` is thrown by Fastify's URL parser BEFORE the request enters the normal handler chain. Confirmed by direct probe: neither `setErrorHandler` nor `onSend` hooks fire for this code path. Fastify writes the 400 response directly via `res.writeHead({...})` / `res.end(body)` in `onBadUrl()` (node_modules/fastify/fastify.js:752–771) when no `frameworkErrors` option is configured. ## Fix Configure Fastify's `frameworkErrors` option in `createServer`. This is the explicit hook Fastify provides for framework-level errors (FST_ERR_BAD_URL and FST_ERR_ASYNC_CONSTRAINT). When set, Fastify routes the error through this function instead of the direct-write path, giving us a Reply object to attach headers to. The handler: - Reads `request.headers.origin`; if present, sets the full JSS CORS header set via `getCorsHeaders(origin)` (ACAO mirrors the request Origin, plus Allow-Methods / Allow- Headers / Expose-Headers / Credentials / Max-Age). - Sends a JSON body matching the prior shape (`error`, `code`, `message`, `statusCode`) — minimal change for clients that were parsing the old format. ## Test plan Three new integration tests against a live JSS server: 1. `%g1` with Origin → 400, ACAO mirrors Origin, full CORS set 2. `%g1` without Origin → 400, JSON body still well-shaped (CORS not enforced when no Origin was sent) 3. Normal 404 path (no FST_ERR) → CORS still injected by the existing wildcard handler — confirms the new hook didn't displace existing behavior Test count: 777 → 780 in full suite. * Address copilot pass 1 on JavaScriptSolidServer#421 Three findings, all valid. 1. The `if (origin)` guard made the frameworkErrors path inconsistent with the rest of the server, which sets CORS on EVERY response via the global onRequest hook (with ACAO defaulting to `*` when no Origin was sent). Removed the guard — getCorsHeaders is now called unconditionally. 2. `error: err.name` produced "FastifyError" (unhelpful), while Fastify's default error body uses the HTTP status text ("Bad Request"). Switched to `STATUS_CODES[statusCode] || 'Error'` from node:http — 400 → "Bad Request", 500 → "Internal Server Error", etc. Matches Fastify's default body shape so any pre-fix client that was parsing `error` keeps working. 3. The "no Origin" test name implied CORS was set with ACAO=* but the assertions only checked the body. Now asserts ACAO=*, ACAM contains GET, ACAH is set, and the body's `error` field is "Bad Request". The "with Origin" test also gained a body-shape assertion on the new `error: "Bad Request"` field so any future regression (like reverting to err.name) is caught. Test count: same 780. * Address copilot pass 2 on JavaScriptSolidServer#421 — doc cleanups Two stale-docs findings, both real. 1. The test file's block comment said the fix attaches CORS "whenever the bad request carried an Origin header" — that was the pass-0 behavior. The pass-1 review (correctly) removed that guard so CORS is now set unconditionally (with ACAO=* when no Origin was sent). Updated the comment to match the actual behavior. 2. The PR description's "After" example body still showed `error: "FastifyError"` from my initial draft, before the pass-1 fix that switched to `STATUS_CODES[statusCode]` ("Bad Request" for 400). Updated the PR body via REST patch so the example matches the shipped response shape. No code or test changes — pure documentation alignment. Test count: same 780.
1 parent b39c6d9 commit 1ee75c7

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

src/server.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Fastify from 'fastify';
22
import rateLimit from '@fastify/rate-limit';
33
import { readFile } from 'fs/promises';
4+
import { STATUS_CODES } from 'node:http';
45
import { join, dirname } from 'path';
56
import { fileURLToPath } from 'url';
67
import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePatch } from './handlers/resource.js';
@@ -169,6 +170,32 @@ export function createServer(options = {}) {
169170
}
170171
// Default Fastify behavior for other client errors
171172
socket.destroy(err);
173+
},
174+
// Catch Fastify-internal errors that fire BEFORE any user hook
175+
// runs — notably FST_ERR_BAD_URL on malformed percent-encoding
176+
// (`%g1`, truncated `%E0%`, invalid UTF-8). Without this, Fastify
177+
// writes the 400 response directly via `res.writeHead` and the
178+
// browser sees a CORS error (no Access-Control-Allow-*) instead
179+
// of the real status. #376.
180+
frameworkErrors: (err, request, reply) => {
181+
// ALWAYS apply CORS headers — matches the rest of the server's
182+
// behavior (every successful response sets CORS via the global
183+
// onRequest hook). getCorsHeaders defaults Allow-Origin to `*`
184+
// when the request didn't send an Origin header.
185+
const cors = getCorsHeaders(request.headers?.origin);
186+
for (const [k, v] of Object.entries(cors)) reply.header(k, v);
187+
const statusCode = err.statusCode ?? 400;
188+
reply.code(statusCode).type('application/json').send({
189+
// Use the HTTP status text (e.g. "Bad Request" for 400)
190+
// rather than err.name (which for FastifyError is the
191+
// unhelpful string "FastifyError"). Matches Fastify's
192+
// default error-body shape that pre-fix clients were
193+
// parsing.
194+
error: STATUS_CODES[statusCode] || 'Error',
195+
code: err.code,
196+
message: err.message,
197+
statusCode,
198+
});
172199
}
173200
};
174201

test/error-handler.test.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,85 @@ describe('registerErrorHandler (#312)', () => {
8181
assert.strictEqual(unhandled.length, 0, '4xx must not produce a 5xx stack log');
8282
});
8383
});
84+
85+
// #376: Fastify-internal errors that fire BEFORE any user hook
86+
// runs (notably FST_ERR_BAD_URL on malformed percent-encoding)
87+
// previously bypassed every CORS-injecting handler in JSS, leaving
88+
// the 400 response with no Access-Control-Allow-* headers — browsers
89+
// surfaced the response as a CORS error instead of the real status.
90+
// The fix uses Fastify's `frameworkErrors` option in createServer
91+
// to attach the full CORS header set on EVERY framework-error
92+
// response (matching the rest of the server, where the global
93+
// onRequest hook sets CORS unconditionally). Allow-Origin mirrors
94+
// the request's `Origin` if present, otherwise defaults to `*`.
95+
describe('frameworkErrors injects CORS headers on FST_ERR_BAD_URL (#376)', () => {
96+
let server;
97+
let baseUrl;
98+
99+
before(async () => {
100+
const { createServer } = await import('../src/server.js');
101+
server = createServer({ logger: false, forceCloseConnections: true });
102+
await server.listen({ port: 0, host: '127.0.0.1' });
103+
const addr = server.server.address();
104+
baseUrl = `http://127.0.0.1:${addr.port}`;
105+
});
106+
107+
after(async () => {
108+
await server.close();
109+
});
110+
111+
it('returns 400 with full CORS headers for a malformed-percent URL + Origin', async () => {
112+
// `%g1` is the canonical FST_ERR_BAD_URL trigger — `g` isn't a
113+
// valid hex digit, so the percent-decode bails before any route
114+
// handler is even resolved.
115+
const r = await fetch(`${baseUrl}/foo%g1`, {
116+
headers: { Origin: 'https://example.com' },
117+
});
118+
assert.strictEqual(r.status, 400);
119+
// CORS headers are what was missing pre-fix. ACAO should mirror
120+
// the request Origin (not `*`), since the request explicitly
121+
// sent one — that's what getCorsHeaders does.
122+
assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://example.com');
123+
assert.match(r.headers.get('access-control-allow-methods') || '', /GET/);
124+
assert.ok(r.headers.get('access-control-allow-headers'), 'ACAH must be set');
125+
assert.ok(r.headers.get('access-control-expose-headers'), 'ACEH must be set');
126+
// Body uses HTTP status text ("Bad Request"), not err.name
127+
// ("FastifyError") — matches Fastify's default body shape so
128+
// any pre-fix client parsing `error` keeps working.
129+
const body = await r.json();
130+
assert.strictEqual(body.error, 'Bad Request');
131+
assert.strictEqual(body.code, 'FST_ERR_BAD_URL');
132+
assert.strictEqual(body.statusCode, 400);
133+
});
134+
135+
it('returns CORS headers (ACAO=*) and well-shaped JSON for the same bad URL without an Origin', async () => {
136+
// Non-browser clients without an Origin still receive the full
137+
// CORS header set — consistent with the rest of the server,
138+
// where the global onRequest hook always sets CORS. ACAO
139+
// defaults to `*` when no Origin was sent (per getCorsHeaders).
140+
const r = await fetch(`${baseUrl}/foo%g1`);
141+
assert.strictEqual(r.status, 400);
142+
assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
143+
assert.match(r.headers.get('access-control-allow-methods') || '', /GET/);
144+
assert.ok(r.headers.get('access-control-allow-headers'), 'ACAH must be set');
145+
const body = await r.json();
146+
assert.strictEqual(body.error, 'Bad Request');
147+
assert.strictEqual(body.code, 'FST_ERR_BAD_URL');
148+
assert.strictEqual(body.statusCode, 400);
149+
});
150+
151+
it('does NOT regress: a normal 404 still carries CORS via the wildcard handler', async () => {
152+
// Belt-and-suspenders: the frameworkErrors hook shouldn't have
153+
// displaced any existing CORS behavior on responses that go
154+
// through the wildcard handler. Ask for a path that hits the
155+
// LDP wildcard and 404s (no such resource), confirm CORS.
156+
const r = await fetch(`${baseUrl}/nonexistent/deep/path`, {
157+
headers: { Origin: 'https://example.com' },
158+
});
159+
// Could be 401 or 404 depending on auth defaults; in either case
160+
// CORS must be present.
161+
assert.ok([401, 404].includes(r.status), `expected 401 or 404, got ${r.status}`);
162+
assert.ok(r.headers.get('access-control-allow-origin'),
163+
'ACAO must be set on the normal-handler error path too');
164+
});
165+
});

0 commit comments

Comments
 (0)