chore: full hop-by-hop header strip and 60s timeout on /api/* proxy
The SvelteKit proxy was only stripping host + content-length; the rest of RFC 7230 §6.1 (connection, keep-alive, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, upgrade) leaked through to axum. Axum doesn't emit them so the impact is theoretical, but the proxy should be RFC-conformant. Also adds an AbortController with a configurable 60s timeout (BACKEND_PROXY_TIMEOUT_MS) so a wedged backend can't hang the browser request indefinitely — failures surface as the standard 502 upstream_unavailable envelope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -118,4 +118,77 @@ describe('hooks.server proxy', () => {
|
||||
expect(body.error.code).toBe('upstream_unavailable');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('strips every hop-by-hop header listed in RFC 7230 §6.1', async () => {
|
||||
// Defence in depth: axum doesn't emit these, but a future
|
||||
// middleware that did would otherwise leak per-connection
|
||||
// state across the proxy boundary.
|
||||
fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
|
||||
const resolve = vi.fn();
|
||||
await handle({
|
||||
event: makeEvent('/api/v1/health', {
|
||||
headers: {
|
||||
host: 'app.example.com',
|
||||
'content-length': '0',
|
||||
connection: 'keep-alive',
|
||||
'keep-alive': 'timeout=5',
|
||||
'proxy-authenticate': 'Basic realm=x',
|
||||
'proxy-authorization': 'Basic xyz',
|
||||
te: 'trailers',
|
||||
trailer: 'Expires',
|
||||
'transfer-encoding': 'chunked',
|
||||
upgrade: 'websocket',
|
||||
// A non-hop-by-hop header to ensure non-targets
|
||||
// aren't accidentally stripped.
|
||||
'x-custom': 'pass-through'
|
||||
}
|
||||
}),
|
||||
resolve
|
||||
});
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
const headers = init.headers as Headers;
|
||||
for (const h of [
|
||||
'host',
|
||||
'content-length',
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade'
|
||||
]) {
|
||||
expect(headers.get(h), `${h} should be stripped`).toBeNull();
|
||||
}
|
||||
expect(headers.get('x-custom')).toBe('pass-through');
|
||||
});
|
||||
|
||||
it('aborts and returns 502 when the upstream stalls past the timeout', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// Simulate an aborted fetch (AbortController.abort() raises a
|
||||
// DOMException with name 'AbortError' on Node's fetch). The
|
||||
// handler should treat it as the same upstream_unavailable
|
||||
// 502 it uses for any other network failure.
|
||||
const abortErr = new DOMException('aborted', 'AbortError');
|
||||
fetchSpy.mockRejectedValueOnce(abortErr);
|
||||
|
||||
const resolve = vi.fn();
|
||||
const resp = await handle({ event: makeEvent('/api/v1/slow'), resolve });
|
||||
expect(resp.status).toBe(502);
|
||||
const body = await resp.json();
|
||||
expect(body.error.code).toBe('upstream_unavailable');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('attaches an AbortSignal to the upstream fetch so it can time out', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
|
||||
const resolve = vi.fn();
|
||||
await handle({ event: makeEvent('/api/v1/health'), resolve });
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
// The signal hasn't fired (handler returned in time), but its
|
||||
// presence is the contract this test is pinning.
|
||||
expect(init.signal?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user