mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
3fbe0418eb
The ESLint rule from #2610 that requires a signal on outbound fetches only matched the platform fetch. The guarded wrappers hand it a spread init, which the rule trusts, so safeFetch(url, undefined, options) passed lint with no timeout, and the dispatcher left undici's five-minute default in place. Nothing capped the body either. Two callers did exactly that on hosts TREK does not control: the Synology thumbnail read (a user-configured NAS) and the place enrichment photo download (a URL out of third-party place data), both buffering the whole answer. The wrappers now carry defaults. A call without a signal waits 30 s for the response headers; the body stays under undici's idle timeout, so a proxied video that pauses with its player is not cut off. timeoutMs adds a deadline over the whole exchange and joins the caller's signal, once for the whole redirect chain. Every body is capped at 64 MB, refused up front when the declared length is larger and failed mid-read otherwise; callers pass a tighter maxBytes, or null where the body is streamed on (pipeAsset, the doc-sync transfers that count their own bytes, the Ollama pull). The two call sites get a signal and an 8 MB cap. The lint selectors now cover safeFetch, safeFetchFollow and safeFetchAdminConfigured as well, including an explicit undefined init, and are named once so the repository block restates the same list. A new test asks ESLint for the options it applies to a path and runs them over snippets, so a selector that stops matching fails a test instead of passing silently.
194 lines
7.6 KiB
TypeScript
194 lines
7.6 KiB
TypeScript
import {
|
|
capResponse,
|
|
DEFAULT_MAX_RESPONSE_BYTES,
|
|
DEFAULT_RESPONSE_TIMEOUT_MS,
|
|
ResponseTooLargeError,
|
|
safeFetch,
|
|
safeFetchAdminConfigured,
|
|
safeFetchFollow,
|
|
safeFetchLlm,
|
|
} from '../../../src/utils/ssrfGuard';
|
|
|
|
import dns from 'dns/promises';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
/**
|
|
* The defaults every SSRF-guarded fetch now carries: a bounded wait for the
|
|
* response headers when the caller passes no signal, an optional deadline for
|
|
* the whole exchange, and a cap on the body. Before, a call such as
|
|
* `safeFetch(url, undefined, options)` reached the platform fetch through a
|
|
* spread init the lint rule could not see into, waited undici's five minutes
|
|
* for a hung host and read whatever size the host sent back into memory.
|
|
*/
|
|
const { AgentMock } = vi.hoisted(() => ({ AgentMock: vi.fn() }));
|
|
vi.mock('undici', () => ({ Agent: AgentMock }));
|
|
|
|
vi.mock('dns/promises', () => ({ default: { lookup: vi.fn() }, lookup: vi.fn() }));
|
|
|
|
const { readEnvMock } = vi.hoisted(() => ({
|
|
readEnvMock: vi.fn(() => ({
|
|
net: { allowInternalNetwork: false, proxy: { noProxy: [] } },
|
|
integrations: { llmTimeoutMs: 900_000 },
|
|
})),
|
|
}));
|
|
vi.mock('../../../src/app-config', () => ({ readEnv: readEnvMock }));
|
|
|
|
const mockLookup = vi.mocked(dns.lookup);
|
|
const agentOptions = () => AgentMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
|
|
/** A real platform Response, as undici hands it back, with the url it would carry. */
|
|
function upstream(body: ConstructorParameters<typeof Response>[0], init: ResponseInit = {}, url = 'https://photos.example/a.jpg'): Response {
|
|
const res = new Response(body, init);
|
|
Object.defineProperty(res, 'url', { value: url });
|
|
return res;
|
|
}
|
|
|
|
/** A body that arrives in chunks and never declares its length. */
|
|
function chunked(chunks: number, size: number): ReadableStream<Uint8Array> {
|
|
let sent = 0;
|
|
return new ReadableStream({
|
|
pull(controller) {
|
|
if (sent++ >= chunks) controller.close();
|
|
else controller.enqueue(new Uint8Array(size));
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
AgentMock.mockClear();
|
|
mockLookup.mockResolvedValue({ address: '203.0.113.10', family: 4 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('the wait for a response', () => {
|
|
it('LIMIT-001: without a signal, the headers are waited for a bounded time and the body is left to undici', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(upstream('ok'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await safeFetch('https://photos.example/a.jpg');
|
|
|
|
expect(agentOptions().headersTimeout).toBe(DEFAULT_RESPONSE_TIMEOUT_MS);
|
|
expect(agentOptions().bodyTimeout).toBeUndefined();
|
|
expect((fetchMock.mock.calls[0][1] as RequestInit).signal).toBeUndefined();
|
|
});
|
|
|
|
it("LIMIT-002: the caller's own signal governs, and goes through unchanged", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(upstream('ok'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const signal = AbortSignal.timeout(5_000);
|
|
|
|
await safeFetchFollow('https://photos.example/a.jpg', { signal });
|
|
|
|
expect(agentOptions().headersTimeout).toBeUndefined();
|
|
expect((fetchMock.mock.calls[0][1] as RequestInit).signal).toBe(signal);
|
|
});
|
|
|
|
it('LIMIT-003: timeoutMs sets a deadline for the whole chain and joins the caller signal', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(upstream(null, { status: 302, headers: { location: 'https://cdn.example/b.jpg' } }))
|
|
.mockResolvedValueOnce(upstream('ok'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const caller = new AbortController();
|
|
|
|
await safeFetchFollow('https://photos.example/a.jpg', { signal: caller.signal }, { timeoutMs: 60_000 });
|
|
|
|
const first = (fetchMock.mock.calls[0][1] as RequestInit).signal!;
|
|
const second = (fetchMock.mock.calls[1][1] as RequestInit).signal!;
|
|
expect(first).toBeInstanceOf(AbortSignal);
|
|
expect(first).not.toBe(caller.signal);
|
|
// One signal for every hop, so a redirect does not restart the clock.
|
|
expect(second).toBe(first);
|
|
caller.abort();
|
|
expect(first.aborted).toBe(true);
|
|
});
|
|
|
|
it('LIMIT-004: timeoutMs alone gives a call without an init its deadline', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(upstream('ok'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await safeFetch('https://photos.example/a.jpg', undefined, { timeoutMs: 1_000 });
|
|
|
|
expect((fetchMock.mock.calls[0][1] as RequestInit).signal).toBeInstanceOf(AbortSignal);
|
|
expect(agentOptions().headersTimeout).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('the size of a response', () => {
|
|
it('LIMIT-005: a declared length over the cap is refused before anything is read', async () => {
|
|
const res = upstream('x', { headers: { 'content-length': '2048' } });
|
|
const cancel = vi.spyOn(res.body!, 'cancel');
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res));
|
|
|
|
await expect(safeFetch('https://photos.example/a.jpg', undefined, { maxBytes: 1024 })).rejects.toBeInstanceOf(
|
|
ResponseTooLargeError,
|
|
);
|
|
expect(cancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it('LIMIT-006: a body without a declared length fails the read once it passes the cap', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(upstream(chunked(10, 512))));
|
|
|
|
const res = await safeFetch('https://photos.example/a.jpg', undefined, { maxBytes: 2048 });
|
|
|
|
await expect(res.arrayBuffer()).rejects.toBeInstanceOf(ResponseTooLargeError);
|
|
});
|
|
|
|
it('LIMIT-007: a body under the cap reads whole, with its status, headers and final url', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(upstream(chunked(3, 100), { status: 203, headers: { 'content-type': 'image/jpeg' } })),
|
|
);
|
|
|
|
const res = await safeFetch('https://photos.example/a.jpg', undefined, { maxBytes: 2048 });
|
|
|
|
expect(res.status).toBe(203);
|
|
expect(res.headers.get('content-type')).toBe('image/jpeg');
|
|
expect(res.url).toBe('https://photos.example/a.jpg');
|
|
expect((await res.arrayBuffer()).byteLength).toBe(300);
|
|
});
|
|
|
|
it('LIMIT-008: the default cap applies when the caller names none', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValue(upstream('x', { headers: { 'content-length': String(DEFAULT_MAX_RESPONSE_BYTES + 1) } })),
|
|
);
|
|
|
|
await expect(safeFetchFollow('https://photos.example/a.jpg')).rejects.toBeInstanceOf(ResponseTooLargeError);
|
|
});
|
|
|
|
it('LIMIT-009: maxBytes null hands back the response untouched, for a body streamed on', async () => {
|
|
const res = upstream('x', { headers: { 'content-length': String(DEFAULT_MAX_RESPONSE_BYTES + 1) } });
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res));
|
|
|
|
await expect(safeFetch('https://photos.example/a.jpg', undefined, { maxBytes: null })).resolves.toBe(res);
|
|
});
|
|
|
|
it('LIMIT-010: the admin-configured lane is capped too, and the LLM lane can lift it', async () => {
|
|
const big = () => upstream('x', { headers: { 'content-length': String(DEFAULT_MAX_RESPONSE_BYTES + 1) } });
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockImplementation(() => Promise.resolve(big())),
|
|
);
|
|
|
|
await expect(
|
|
safeFetchAdminConfigured('https://idp.example/jwks', { signal: AbortSignal.timeout(1_000) }),
|
|
).rejects.toBeInstanceOf(ResponseTooLargeError);
|
|
await expect(safeFetchLlm('https://llm.example/api/pull', { method: 'POST' }, 5, null)).resolves.toBeInstanceOf(
|
|
Response,
|
|
);
|
|
});
|
|
|
|
it('LIMIT-011: capResponse leaves a body-less answer and a test stub alone', () => {
|
|
const empty = upstream(null, { status: 204 });
|
|
expect(capResponse(empty, 10)).toBe(empty);
|
|
const stub = { status: 200, headers: { get: () => null }, body: null } as unknown as Response;
|
|
expect(capResponse(stub, 10)).toBe(stub);
|
|
});
|
|
});
|