mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
`reply.raw.writeHead()` writes straight to the Node response and bypasses Fastify's header store, so everything the `onRequest` security hook granted is silently dropped on every route that answers that way. The visible symptom is CORS. The hook emits `Access-Control-Allow-Origin` for localhost origins, so a page served from a local dev server may call every `/api` endpoint cross-origin — except the four below, whose requests fail. The security headers (`X-Content-Type-Options`, `X-Frame-Options`, CSP) were being lost the same way. Affected: `GET /api/events`, and `file-raw` / `tail-file` / `download` in file-routes.ts. Each now spreads the inherited headers first and lets its own headers win over them. Tests drive a real WebServer and compare `/api/events` against `/api/status` for the same Origin — the point of the fix being that the SSE route stops being the odd one out. Verified in both directions: with the fix removed, 3 of the 5 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
/**
|
|
* @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'));
|
|
});
|
|
});
|