Files
TREK/server/tests/unit/nest/doc-sync/provider-http.test.ts
T
Maurice 3fbe0418eb fix(server): bound every SSRF-guarded fetch in time and size
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.
2026-10-08 13:04:38 +02:00

331 lines
13 KiB
TypeScript

/**
* The transport the four document clients share.
*
* The clients' own suites pin what each product puts on the wire; this one
* pins the pieces they all stand on, because a regression here breaks five
* providers at once: which failures read as a timeout, a certificate or a dead
* host, that the timeout and the self-signed switch reach the fetch, and that
* a download can neither announce nor stream its way past its ceiling.
*
* Only `safeFetch` is mocked. `cappedFetch` runs for real, so releasing an
* oversized body is the release that ships.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { safeFetchMock, SsrfBlockedErrorMock } = vi.hoisted(() => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
return { safeFetchMock: vi.fn(), SsrfBlockedErrorMock: SsrfBlockedError };
});
vi.mock('../../../../src/utils/ssrfGuard', () => ({
safeFetch: safeFetchMock,
SsrfBlockedError: SsrfBlockedErrorMock,
}));
import {
classifyTransportFailure,
declaredLength,
guardDownload,
isUnresolvedHost,
providerFetch,
statusErrorCode,
type TransportFailure,
} from '../../../../src/nest/doc-sync/providers/provider-http';
/** `TypeError: fetch failed` with the real reason underneath, the way undici reports it. */
function wrapped(cause: Error): Error {
return new TypeError('fetch failed', { cause });
}
function coded(message: string, code: string): Error {
return Object.assign(new Error(message), { code });
}
function named(message: string, name: string): Error {
return Object.assign(new Error(message), { name });
}
class TransportError extends Error {
constructor(readonly failure: TransportFailure) {
super(`transport: ${failure.code}`);
}
}
const OPTIONS = {
timeoutMs: 5000,
allowInsecureTls: false,
onTransportFailure: (failure: TransportFailure) => new TransportError(failure),
};
/** A body that hands out `chunk`-sized pieces until `total` bytes, recording a cancel. */
function source(chunk: number, total: number): { stream: ReadableStream<Uint8Array>; cancelled: () => boolean } {
let sent = 0;
let cancelled = false;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= total) {
controller.close();
return;
}
sent += chunk;
controller.enqueue(new Uint8Array(chunk).fill(97));
},
cancel() {
cancelled = true;
},
});
return { stream, cancelled: () => cancelled };
}
function download(body: ReadableStream<Uint8Array> | null, headers: Record<string, string> = {}): Response {
return { ok: true, status: 200, headers: new Headers(headers), body } as unknown as Response;
}
class TooLarge extends Error {
constructor(readonly declared: number) {
super('too large');
}
}
const GUARD = {
maxBytes: 4096,
tooLarge: (declared: number) => new TooLarge(declared),
noBody: () => new Error('no body'),
};
async function drain(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk as Uint8Array));
return Buffer.concat(chunks);
}
beforeEach(() => {
safeFetchMock.mockReset();
});
describe('classifyTransportFailure', () => {
it('PROVIDER-HTTP-001: the SSRF guard is its own verdict and keeps its reason', () => {
expect(classifyTransportFailure(new SsrfBlockedErrorMock('Requests to loopback are not allowed'))).toEqual({
code: 'ssrf_blocked',
detail: 'Requests to loopback are not allowed',
});
});
it.each([
['TimeoutError, as AbortSignal.timeout rejects', named('The operation was aborted due to timeout', 'TimeoutError')],
['AbortError', named('This operation was aborted', 'AbortError')],
['TimeoutError under undici\'s wrapper', wrapped(named('aborted', 'TimeoutError'))],
['UND_ERR_HEADERS_TIMEOUT', wrapped(coded('Headers Timeout Error', 'UND_ERR_HEADERS_TIMEOUT'))],
['UND_ERR_BODY_TIMEOUT', wrapped(coded('Body Timeout Error', 'UND_ERR_BODY_TIMEOUT'))],
['ABORT_ERR', wrapped(coded('The operation was aborted', 'ABORT_ERR'))],
])('PROVIDER-HTTP-002: a timeout, spelled as %s', (_label, error) => {
expect(classifyTransportFailure(error).code).toBe('timeout');
});
it.each([
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
// The private-CA case: the chain is fine, the issuer is simply unknown here.
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'CERT_HAS_EXPIRED',
'CERT_SIGNATURE_FAILURE',
'ERR_TLS_CERT_ALTNAME_INVALID',
'HOSTNAME_MISMATCH',
// A leaf without serverAuth, signed by a CA this host does trust.
'INVALID_PURPOSE',
'INVALID_CA',
'PATH_LENGTH_EXCEEDED',
])('PROVIDER-HTTP-003: %s is an untrusted certificate', (code) => {
expect(classifyTransportFailure(wrapped(coded('certificate verdict', code))).code).toBe('tls_untrusted');
});
it('PROVIDER-HTTP-004: a protocol failure in the handshake is not offered as a certificate problem', () => {
// https against a plain http port. The self-signed switch cannot fix it.
const error = wrapped(coded('wrong version number', 'ERR_SSL_WRONG_VERSION_NUMBER'));
expect(classifyTransportFailure(error).code).toBe('unreachable');
});
it('PROVIDER-HTTP-005: everything else is a host that cannot be reached, with the whole chain as detail', () => {
const error = wrapped(coded('connect ECONNREFUSED 192.168.1.9:5001', 'ECONNREFUSED'));
expect(classifyTransportFailure(error)).toEqual({
code: 'unreachable',
detail: 'fetch failed: connect ECONNREFUSED 192.168.1.9:5001',
});
expect(classifyTransportFailure('socket hang up')).toEqual({ code: 'unreachable', detail: 'socket hang up' });
});
it('PROVIDER-HTTP-006: the cause chain is followed five levels and no further', () => {
let error: Error = coded('self-signed certificate', 'DEPTH_ZERO_SELF_SIGNED_CERT');
for (let level = 0; level < 4; level++) error = wrapped(error);
expect(classifyTransportFailure(error).code).toBe('tls_untrusted');
expect(classifyTransportFailure(wrapped(error)).code).toBe('unreachable');
});
it('PROVIDER-HTTP-007: a host that never takes the connection is unreachable, not slow', () => {
// undici gives up on the handshake after ten seconds, before the request's
// own timeout does: a NAS that is switched off, or a firewall that drops the SYN.
const error = wrapped(
Object.assign(new Error('Connect Timeout Error'), { name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT' }),
);
expect(classifyTransportFailure(error).code).toBe('unreachable');
});
it('PROVIDER-HTTP-008: a name DNS cannot resolve is unreachable, not a policy verdict', () => {
// The guard refuses it the same way it refuses loopback, but a typo in the
// host name is not something the SSRF settings can fix.
const error = new SsrfBlockedErrorMock('Could not resolve hostname (ENOTFOUND)');
expect(classifyTransportFailure(error)).toEqual({
code: 'unreachable',
detail: 'Could not resolve hostname (ENOTFOUND)',
});
expect(isUnresolvedHost('Could not resolve hostname (EAI_AGAIN)')).toBe(true);
expect(isUnresolvedHost('Requests to loopback are not allowed')).toBe(false);
});
});
describe('providerFetch', () => {
it('PROVIDER-HTTP-010: hands the request to the SSRF guard with a timeout and the certificate check on', async () => {
const answer = { ok: true, status: 200 };
safeFetchMock.mockResolvedValue(answer);
const body = new URLSearchParams({ a: '1' });
const response = await providerFetch(
'https://nas.example.org:5001/webapi/entry.cgi',
{ method: 'POST', headers: { Accept: 'application/json' }, body, duplex: 'half' } as RequestInit,
OPTIONS,
);
expect(response).toBe(answer);
const [url, init, options] = safeFetchMock.mock.calls[0] as [string, RequestInit & { duplex?: string }, unknown];
expect(url).toBe('https://nas.example.org:5001/webapi/entry.cgi');
expect(init).toMatchObject({ method: 'POST', headers: { Accept: 'application/json' }, body, duplex: 'half' });
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(options).toEqual({ rejectUnauthorized: true, maxBytes: null });
});
it('PROVIDER-HTTP-011: relaxes the certificate check only when the connection says so', async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await providerFetch('https://nas.example.org', {}, { ...OPTIONS, allowInsecureTls: true });
expect(safeFetchMock.mock.calls[0][2]).toEqual({ rejectUnauthorized: false, maxBytes: null });
});
it('PROVIDER-HTTP-012: an instance that never answers is cut off at the timeout', async () => {
safeFetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(init.signal?.reason));
}),
);
const pending = providerFetch('https://nas.example.org', {}, { ...OPTIONS, timeoutMs: 20 });
await expect(pending).rejects.toBeInstanceOf(TransportError);
await expect(pending).rejects.toMatchObject({ failure: { code: 'timeout' } });
});
it('PROVIDER-HTTP-013: a refusal becomes the client\'s own error, built from the classified failure', async () => {
safeFetchMock.mockRejectedValue(new SsrfBlockedErrorMock('Requests to link-local addresses are not allowed'));
const onTransportFailure = vi.fn((failure: TransportFailure) => new TransportError(failure));
const pending = providerFetch('http://169.254.169.254/', {}, { ...OPTIONS, onTransportFailure });
await expect(pending).rejects.toMatchObject({
failure: { code: 'ssrf_blocked', detail: 'Requests to link-local addresses are not allowed' },
});
expect(onTransportFailure).toHaveBeenCalledTimes(1);
});
});
describe('statusErrorCode', () => {
it('PROVIDER-HTTP-020: maps the statuses every product answers alike', () => {
const expected: Array<[number, string]> = [
[401, 'unauthorized'],
[403, 'forbidden'],
[404, 'not_found'],
[409, 'conflict'],
[413, 'too_large'],
[415, 'unsupported_type'],
[429, 'rate_limited'],
[507, 'quota_exceeded'],
[500, 'provider_error'],
[418, 'provider_error'],
];
for (const [status, code] of expected) expect(statusErrorCode(status), `http ${status}`).toBe(code);
});
it('PROVIDER-HTTP-021: a client\'s own reading of a status wins, and its fallback answers the rest', () => {
expect(statusErrorCode(412, { 412: 'conflict' })).toBe('conflict');
expect(statusErrorCode(404, { 404: 'scope_missing' })).toBe('scope_missing');
expect(statusErrorCode(418, {}, 'unknown')).toBe('unknown');
expect(statusErrorCode(401, {}, 'unknown')).toBe('unauthorized');
});
});
describe('declaredLength', () => {
it('PROVIDER-HTTP-030: an absent or unreadable header is an unknown size, never zero', () => {
expect(declaredLength(null)).toBeNull();
expect(declaredLength('')).toBeNull();
expect(declaredLength(' ')).toBeNull();
expect(declaredLength('many')).toBeNull();
expect(declaredLength('-1')).toBeNull();
expect(declaredLength('0')).toBe(0);
expect(declaredLength('4096')).toBe(4096);
});
});
describe('guardDownload', () => {
it('PROVIDER-HTTP-040: streams a body within the limit and reports what it announced', async () => {
const { stream } = source(1024, 3072);
const guarded = guardDownload(download(stream, { 'content-length': '3072' }), GUARD);
expect(guarded.size).toBe(3072);
expect((await drain(guarded.body)).length).toBe(3072);
});
it('PROVIDER-HTTP-041: refuses an announced length over the limit before reading, and lets the socket go', () => {
const { stream, cancelled } = source(1024, 8192);
let thrown: unknown;
try {
guardDownload(download(stream, { 'content-length': '8192' }), GUARD);
} catch (error: unknown) {
thrown = error;
}
expect(thrown).toBeInstanceOf(TooLarge);
expect((thrown as TooLarge).declared).toBe(8192);
expect(cancelled()).toBe(true);
});
it('PROVIDER-HTTP-042: a body that announces nothing and runs past the limit fails mid-stream', async () => {
const { stream, cancelled } = source(1024, 64 * 1024);
const guarded = guardDownload(download(stream), GUARD);
expect(guarded.size).toBeNull();
await expect(drain(guarded.body)).rejects.toThrow(/exceeded 4096 bytes/);
await vi.waitFor(() => expect(cancelled()).toBe(true));
});
it('PROVIDER-HTTP-043: a body that lies about its length is held to the limit all the same', async () => {
const { stream } = source(1024, 64 * 1024);
const guarded = guardDownload(download(stream, { 'content-length': '100' }), GUARD);
await expect(drain(guarded.body)).rejects.toThrow(/exceeded/);
});
it('PROVIDER-HTTP-044: a consumer that stops reading releases the source', async () => {
const { stream, cancelled } = source(1024, 64 * 1024);
const guarded = guardDownload(download(stream), { ...GUARD, maxBytes: 1024 * 1024 });
guarded.body.destroy();
await vi.waitFor(() => expect(cancelled()).toBe(true));
});
it('PROVIDER-HTTP-045: an answer without a body is the client\'s own error', () => {
expect(() => guardDownload(download(null, { 'content-length': '10' }), GUARD)).toThrow('no body');
});
});