Merge pull request #224 from christianhaberl/fix/raw-writehead-drops-security-headers

fix(http): raw writeHead routes drop every header the security hook set
This commit is contained in:
Ark0N
2026-08-08 01:30:47 +02:00
committed by GitHub
4 changed files with 144 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* @fileoverview `/api/events` must not lose the headers the security hook set.
*
* The SSE route answers with `reply.raw.writeHead()`, which writes straight to the
* Node response and bypasses Fastify's header store. Everything the `onRequest`
* security hook had granted was therefore dropped — including the
* `Access-Control-Allow-Origin` it emits for localhost origins. The contradiction is
* visible from a browser: a localhost page may call every other `/api` endpoint
* cross-origin, but its EventSource fails CORS.
*
* These tests drive a REAL WebServer. An earlier version asserted against an inline
* copy of the hook and the handler, which proved nothing: reverting the fix in
* `server.ts` left every test green.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
const TEST_PORT = 3119;
const LOCAL_ORIGIN = 'http://localhost:5173';
/** Open /api/events, read the response headers, then abort — it never ends on its own. */
async function eventsHeaders(baseUrl: string, origin?: string): Promise<Headers> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch(`${baseUrl}/api/events`, {
signal: controller.signal,
headers: origin ? { Origin: origin } : undefined,
});
const headers = res.headers;
controller.abort(); // stop consuming the stream
return headers;
} finally {
clearTimeout(timeout);
}
}
describe('GET /api/events header inheritance', () => {
let server: WebServer;
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
});
afterAll(async () => {
await server.stop();
}, 60000);
it('keeps the CORS header the security hook granted a localhost origin', async () => {
// The regression: this header is set on the Fastify reply and was then thrown
// away by writeHead, so an EventSource from a localhost dev server failed CORS
// while every other endpoint worked.
const headers = await eventsHeaders(baseUrl, LOCAL_ORIGIN);
expect(headers.get('access-control-allow-origin')).toBe(LOCAL_ORIGIN);
});
it('keeps the security headers the hook set', async () => {
const headers = await eventsHeaders(baseUrl);
expect(headers.get('x-content-type-options')).toBe('nosniff');
expect(headers.get('x-frame-options')).toBe('SAMEORIGIN');
expect(headers.get('content-security-policy')).toBeTruthy();
});
it('still sets the SSE headers, and they win over anything inherited', async () => {
const headers = await eventsHeaders(baseUrl);
expect(headers.get('content-type')).toBe('text/event-stream');
expect(headers.get('cache-control')).toBe('no-cache');
expect(headers.get('x-accel-buffering')).toBe('no');
});
it('grants nothing to a non-localhost origin — the hook decides, not this route', async () => {
const headers = await eventsHeaders(baseUrl, 'https://evil.example');
expect(headers.get('access-control-allow-origin')).toBeNull();
});
it('matches what a normal JSON endpoint returns for the same origin', async () => {
// The point of the fix: /api/events stops being the odd one out.
const json = await fetch(`${baseUrl}/api/status`, { headers: { Origin: LOCAL_ORIGIN } });
const sse = await eventsHeaders(baseUrl, LOCAL_ORIGIN);
expect(sse.get('access-control-allow-origin')).toBe(json.headers.get('access-control-allow-origin'));
expect(sse.get('x-content-type-options')).toBe(json.headers.get('x-content-type-options'));
});
});