mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26: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.
134 lines
5.5 KiB
TypeScript
134 lines
5.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
/**
|
|
* The LLM lane's response ceiling, pinned at the two seams where it is decided:
|
|
* the dispatcher that carries it, and the choice of which callers get it.
|
|
*
|
|
* `createPinnedDispatcher` grew a `responseTimeoutMs` parameter and nothing
|
|
* passed it, so the ceiling stayed undici's default five minutes while
|
|
* LLM_TIMEOUT_MS said fifteen — one setting overruled by another nobody chose.
|
|
* A capability nobody calls looks exactly like a working fix from the outside,
|
|
* which is why this asserts the argument reaches the dispatcher rather than
|
|
* that the option exists.
|
|
*
|
|
* The lane matters as much as the number. `safeFetchAdminConfigured` is also
|
|
* the OIDC lane (discovery, token, userinfo, JWKS) and the plugin OAuth token
|
|
* exchange, and the plugin one sends no AbortSignal of its own: a ceiling
|
|
* applied there would hold a hung identity provider open for a quarter of an
|
|
* hour on the strength of a model setting.
|
|
*/
|
|
const { AgentMock } = vi.hoisted(() => ({ AgentMock: vi.fn() }));
|
|
vi.mock('undici', () => ({ Agent: AgentMock }));
|
|
|
|
vi.mock('dns/promises', () => ({ default: { lookup: vi.fn() }, lookup: vi.fn() }));
|
|
|
|
// ssrfGuard reads env at module load, so the mock must answer before the import.
|
|
const { readEnvMock } = vi.hoisted(() => ({
|
|
readEnvMock: vi.fn(() => ({ net: { allowInternalNetwork: true, proxy: { noProxy: [] } }, integrations: { llmTimeoutMs: 900_000 } })),
|
|
}));
|
|
vi.mock('../../../src/app-config', () => ({ readEnv: readEnvMock }));
|
|
|
|
import dns from 'dns/promises';
|
|
import {
|
|
createPinnedDispatcher,
|
|
DEFAULT_RESPONSE_TIMEOUT_MS,
|
|
safeFetchAdminConfigured,
|
|
safeFetchLlm,
|
|
} from '../../../src/utils/ssrfGuard';
|
|
|
|
const mockLookup = vi.mocked(dns.lookup);
|
|
|
|
beforeEach(() => {
|
|
AgentMock.mockClear();
|
|
readEnvMock.mockClear();
|
|
readEnvMock.mockReturnValue({ net: { allowInternalNetwork: true, proxy: { noProxy: [] } }, integrations: { llmTimeoutMs: 900_000 } });
|
|
mockLookup.mockResolvedValue({ address: '203.0.113.10', family: 4 });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
const optionsOf = () => AgentMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
|
|
|
function response(opts: { status: number; location?: string }) {
|
|
return {
|
|
status: opts.status,
|
|
ok: opts.status >= 200 && opts.status < 300,
|
|
headers: { get: (h: string) => (h.toLowerCase() === 'location' ? (opts.location ?? null) : null) },
|
|
body: { cancel: () => Promise.resolve() },
|
|
};
|
|
}
|
|
|
|
describe('createPinnedDispatcher — response ceiling', () => {
|
|
it('applies the ceiling it is given to both header and body waits', () => {
|
|
createPinnedDispatcher('10.0.0.5', true, 900_000);
|
|
|
|
expect(optionsOf().headersTimeout).toBe(900_000);
|
|
expect(optionsOf().bodyTimeout).toBe(900_000);
|
|
});
|
|
|
|
it('leaves undici its own default when no ceiling is given', () => {
|
|
createPinnedDispatcher('10.0.0.5', true);
|
|
|
|
expect(optionsOf().headersTimeout).toBeUndefined();
|
|
expect(optionsOf().bodyTimeout).toBeUndefined();
|
|
});
|
|
|
|
it('still pins the connection to the validated IP', () => {
|
|
createPinnedDispatcher('10.0.0.5', true, 900_000);
|
|
|
|
const lookup = (optionsOf().connect as { lookup: (h: string, o: object, cb: (...a: unknown[]) => void) => void }).lookup;
|
|
const seen: unknown[] = [];
|
|
lookup('evil.example', {}, (...args: unknown[]) => seen.push(...args));
|
|
expect(seen).toContain('10.0.0.5');
|
|
});
|
|
});
|
|
|
|
describe('the ceiling belongs to the model lane only', () => {
|
|
it('safeFetchLlm carries the configured ceiling to the dispatcher', async () => {
|
|
readEnvMock.mockReturnValue({ net: { allowInternalNetwork: true, proxy: { noProxy: [] } }, integrations: { llmTimeoutMs: 120_000 } });
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ status: 200 })));
|
|
|
|
await safeFetchLlm('https://api.provider.example/v1/chat/completions');
|
|
|
|
expect(optionsOf().headersTimeout).toBe(120_000);
|
|
expect(optionsOf().bodyTimeout).toBe(120_000);
|
|
});
|
|
|
|
it('safeFetchAdminConfigured leaves the deadline to the caller signal: OIDC and plugin OAuth ride this lane', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ status: 200 })));
|
|
|
|
await safeFetchAdminConfigured('https://idp.example/token', { method: 'POST', signal: AbortSignal.timeout(15_000) });
|
|
|
|
expect(optionsOf().headersTimeout).toBeUndefined();
|
|
expect(optionsOf().bodyTimeout).toBeUndefined();
|
|
});
|
|
|
|
it('safeFetchAdminConfigured without a signal waits a bounded time for the headers, not for the body', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ status: 200 })));
|
|
|
|
await safeFetchAdminConfigured('https://idp.example/token', { method: 'POST' });
|
|
|
|
expect(optionsOf().headersTimeout).toBe(DEFAULT_RESPONSE_TIMEOUT_MS);
|
|
expect(optionsOf().bodyTimeout).toBeUndefined();
|
|
});
|
|
|
|
it('reads the ceiling once per call, so every hop of a redirect chain uses one value', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce(response({ status: 302, location: 'https://api.provider.example/v2/chat' }))
|
|
.mockResolvedValueOnce(response({ status: 200 })),
|
|
);
|
|
|
|
await safeFetchLlm('https://api.provider.example/v1/chat');
|
|
|
|
expect(AgentMock).toHaveBeenCalledTimes(2);
|
|
expect(readEnvMock).toHaveBeenCalledTimes(1);
|
|
expect((AgentMock.mock.calls[0][0] as Record<string, unknown>).headersTimeout).toBe(900_000);
|
|
expect((AgentMock.mock.calls[1][0] as Record<string, unknown>).headersTimeout).toBe(900_000);
|
|
});
|
|
});
|