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.
This commit is contained in:
Maurice
2026-10-08 11:54:21 +02:00
parent 8e6c30f883
commit 3fbe0418eb
19 changed files with 579 additions and 110 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`c
- **Transactions are not optional.** Multi-statement writes go in `await this.uow.transactional(async () => …)` (`UnitOfWork` from `nest/database`, provided by the global `OrmModule`); never hand-roll `BEGIN`/`COMMIT` and never call `db.transaction()` (the Phase 1 async sweep removes it). Every DB-touching method is `async`; `@typescript-eslint/no-floating-promises` and `no-misused-promises` are errors, so an unawaited call or a `Promise` in a condition fails lint. Bind values with `?`; identifiers only via a literal allow-list.
- **Migrations are append-only.** A migration is identified by its class name in `mikro_orm_migrations`, and files sort by the timestamp in that name — never rename or delete one that has shipped, and never edit its body; add a new one. No env interpolation in SQL. (The old positional `schema_version` array and the hand-kept `db/migrations.ts` test builder are both gone; the numbered migrations are the only schema history, for tests too.) A new foreign key comes with an index that leads with its column(s), or deleting the parent scans the child table; `tests/unit/db/foreign-key-indexes.test.ts` fails on one without.
- **Every write endpoint validates** through the Zod pipe with a schema from `@trek/shared` — no `Record<string, unknown>` + ad-hoc checks. Also verify every referenced id exists and belongs to the same trip.
- **Every outbound `fetch`** gets a timeout, a response-size cap and boundary validation — no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key.
- **Every outbound `fetch`** gets a timeout, a response-size cap and boundary validation — no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key. ESLint requires an init with a `signal` on `fetch`, `safeFetch`, `safeFetchFollow` and `safeFetchAdminConfigured` alike (`safeFetchLlm` takes `LLM_TIMEOUT_MS` inside). Behind that, the guarded wrappers (`utils/ssrfGuard.ts`) cap every body at `DEFAULT_MAX_RESPONSE_BYTES` (64 MB) and, for a call without a signal, wait `DEFAULT_RESPONSE_TIMEOUT_MS` (30 s) for the headers. Pass a tighter `maxBytes` when you buffer a small answer, `timeoutMs` for a deadline over the whole exchange, and `maxBytes: null` only where the body is streamed on or counted by the caller (`pipeAsset`, the doc-sync transfers, the Ollama pull).
- **Fail closed.** Unrecognized flag values mean OFF; sandbox/permission opt-outs refuse in production; never pick a less-safe path based on the *absence* of a file. GET handlers must not write.
- **Don't fork canonical paths.** Auth verification is `verifyJwtAndLoadUser` (with the `password_version` gate) — reuse it.
- **Money**: integer cents; largest-remainder splits (`BudgetService.splitEqualShares` is the reference); FX frozen at entry; explicit flags, never sentinel values; custom splits must reconcile to zero before persisting.
+62 -64
View File
@@ -9,6 +9,66 @@ import noPromiseAsValue from './eslint-rules/no-promise-as-value.mjs';
// Local rules live in eslint-rules/ and are exposed under the `trek/` prefix.
const trek = { rules: { 'no-promise-as-value': noPromiseAsValue } };
// The calls the outbound-timeout selectors below look at: the platform fetch and
// the SSRF-guarded wrappers whose init is passed on to it. safeFetchLlm is not
// one of them; its deadline is the LLM_TIMEOUT_MS setting, applied inside.
const FETCH_CALLEE = '/^(fetch|safeFetch|safeFetchFollow|safeFetchAdminConfigured)$/';
// The no-restricted-syntax selector groups. A flat-config block's options REPLACE
// those of an earlier block for the same files rather than adding to them, so a
// block that adds one group has to restate the others; naming them once keeps
// the restatements identical.
const ENV_SELECTORS = [
{
selector: "MemberExpression[object.name='process'][property.name='env']",
message:
'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.',
},
{
// Bracket-notation variant (process['env']) — property is a Literal
// node (.value), so the selector above can't match it.
selector: "MemberExpression[object.name='process'][property.value='env']",
message:
'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.',
},
];
const RAW_SQL_SELECTORS = [
{
selector: "CallExpression[callee.name='raw']",
message:
'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.',
},
{
selector: "TaggedTemplateExpression[tag.name='sql']",
message:
'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.',
},
];
const FETCH_SELECTORS = [
{
// Every outbound fetch needs a timeout (server/CLAUDE.md): a provider
// that takes the connection and goes quiet otherwise holds the request
// for undici's 300 s default. An init built elsewhere (a variable or a
// spread) is trusted to carry its own signal. The SSRF-guarded
// wrappers in utils/ssrfGuard.ts are held to the same rule: they hand
// the platform a spread init, so the rule cannot see through them, and
// their own fallback only bounds the wait for the headers.
selector: `CallExpression[callee.name=${FETCH_CALLEE}][arguments.length=1]`,
message: 'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }.',
},
{
selector: `CallExpression[callee.name=${FETCH_CALLEE}] > ObjectExpression.arguments:nth-child(2):not(:has(Property[key.name='signal'])):not(:has(SpreadElement))`,
message: 'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }.',
},
{
// `safeFetch(url, undefined, options)` is the same call without an init.
selector: `CallExpression[callee.name=${FETCH_CALLEE}] > Identifier.arguments:nth-child(2)[name='undefined']`,
message: 'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }.',
},
];
export default tseslint.config(
gitignore({ strict: false }),
{
@@ -116,34 +176,7 @@ export default tseslint.config(
'src/nest/plugins/host/plugin-audit.ts',
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "MemberExpression[object.name='process'][property.name='env']",
message:
'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.',
},
{
// Bracket-notation variant (process['env']) — property is a Literal
// node (.value), so the selector above can't match it.
selector: "MemberExpression[object.name='process'][property.value='env']",
message:
'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.',
},
{
// Every outbound fetch needs a timeout (server/CLAUDE.md): a provider
// that takes the connection and goes quiet otherwise holds the request
// for undici's 300 s default. An init built elsewhere (a variable or a
// spread) is trusted to carry its own signal.
selector: "CallExpression[callee.name='fetch'][arguments.length=1]",
message: 'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }.',
},
{
selector:
"CallExpression[callee.name='fetch'] > ObjectExpression:not(:has(Property[key.name='signal'])):not(:has(SpreadElement))",
message: 'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }.',
},
],
'no-restricted-syntax': ['error', ...ENV_SELECTORS, ...FETCH_SELECTORS],
},
},
{
@@ -263,42 +296,7 @@ export default tseslint.config(
],
},
],
'no-restricted-syntax': [
'error',
{
selector: "MemberExpression[object.name='process'][property.name='env']",
message:
'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.',
},
{
selector: "MemberExpression[object.name='process'][property.value='env']",
message:
'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.',
},
{
selector: "CallExpression[callee.name='raw']",
message:
'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.',
},
{
selector: "TaggedTemplateExpression[tag.name='sql']",
message:
'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.',
},
{
// Every outbound fetch needs a timeout (server/CLAUDE.md): a provider
// that takes the connection and goes quiet otherwise holds the request
// for undici's 300 s default. An init built elsewhere (a variable or a
// spread) is trusted to carry its own signal.
selector: "CallExpression[callee.name='fetch'][arguments.length=1]",
message: 'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }.',
},
{
selector:
"CallExpression[callee.name='fetch'] > ObjectExpression:not(:has(Property[key.name='signal'])):not(:has(SpreadElement))",
message: 'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }.',
},
],
'no-restricted-syntax': ['error', ...ENV_SELECTORS, ...RAW_SQL_SELECTORS, ...FETCH_SELECTORS],
},
},
{
@@ -167,7 +167,9 @@ export async function providerFetch(url: string, init: RequestInit, options: Pro
return await safeFetch(
url,
{ ...init, signal: AbortSignal.timeout(options.timeoutMs) as AbortSignal },
{ rejectUnauthorized: !options.allowInsecureTls },
// The transfers count their own bytes against DOWNLOAD_MAX_BYTES and the
// JSON reads against PROVIDER_JSON_MAX_BYTES, both read capped.
{ rejectUnauthorized: !options.allowInsecureTls, maxBytes: null },
);
} catch (err: unknown) {
throw options.onTransportFailure(classifyTransportFailure(err));
+12 -5
View File
@@ -69,11 +69,18 @@ export class LlmLocalService {
const root = this.ollamaRoot(baseUrl);
let res: Response;
try {
res = await safeFetchLlm(`${root}/api/pull`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: model.trim(), stream: true }),
});
// The progress lines stream on to the client for as long as the pull
// runs, so the body is not capped; LLM_TIMEOUT_MS bounds each pause.
res = await safeFetchLlm(
`${root}/api/pull`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: model.trim(), stream: true }),
},
5,
null,
);
} catch {
throw new HttpException({ error: `Could not reach local LLM server at ${root}` }, 502);
}
+3 -1
View File
@@ -321,7 +321,9 @@ export function describeFetchFailure(err: unknown): string {
*/
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal, defaultCacheControl?: string, fetchOptions?: SafeFetchOptions): Promise<void> {
try {
const resp = await safeFetch(url, { headers, signal: signal as any }, fetchOptions);
// Streamed straight through to the client, never held in memory, so a
// video original runs past the body cap a buffered read is held to.
const resp = await safeFetch(url, { headers, signal: signal as any }, { ...fetchOptions, maxBytes: null });
response.status(resp.status);
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
+10 -1
View File
@@ -29,6 +29,12 @@ const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';
const SYNOLOGY_DEVICE_NAME = 'trek';
// A cached thumbnail read: Synology's 'm' size is a few hundred kilobytes, so
// eight megabytes is room to spare, and the NAS is a user-configured host that
// must not hold the request or fill memory when it misbehaves.
const SYNOLOGY_THUMBNAIL_TIMEOUT_MS = 30_000;
const SYNOLOGY_THUMBNAIL_MAX_BYTES = 8 * 1024 * 1024;
const SYNOLOGY_ERROR_MESSAGES: Record<number, string> = {
101: 'Missing API, method, or version parameter.',
102: 'Requested API does not exist.',
@@ -675,7 +681,10 @@ export class SynologyService {
const url = this._buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
try {
const resp = await safeFetch(url, undefined, { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl });
const resp = await safeFetch(url, { signal: AbortSignal.timeout(SYNOLOGY_THUMBNAIL_TIMEOUT_MS) }, {
rejectUnauthorized: !synology_credentials.data.synology_skip_ssl,
maxBytes: SYNOLOGY_THUMBNAIL_MAX_BYTES,
});
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const bytes = Buffer.from(await resp.arrayBuffer());
@@ -40,6 +40,12 @@ import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.servic
const COMMONS_CAP = 5;
const GOOGLE_CAP = 3;
// A photo from Wikimedia or another third-party URL the place data names: the
// same ceiling the maps photo proxy holds Wikimedia to, and a deadline so a
// slow image host cannot hold the enrichment request open.
const REMOTE_PHOTO_TIMEOUT_MS = 15_000;
const REMOTE_PHOTO_MAX_BYTES = 8 * 1024 * 1024;
/**
* Enrichment results live in the same table as the plain details cache, under a
* third `expanded` value. Descriptions and the set of pictures near a place
@@ -600,7 +606,11 @@ export class PlaceEnrichmentService {
/** Downloads a non-Google image, re-checking every redirect hop against the SSRF guard. */
private async fetchRemoteBytes(url: string): Promise<Buffer | null> {
try {
const res = await safeFetchFollow(url, undefined, { bypassInternalIpAllowed: true });
const res = await safeFetchFollow(
url,
{ signal: AbortSignal.timeout(REMOTE_PHOTO_TIMEOUT_MS) },
{ bypassInternalIpAllowed: true, maxBytes: REMOTE_PHOTO_MAX_BYTES },
);
if (!res.ok) return null;
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length ? bytes : null;
+129 -17
View File
@@ -256,17 +256,20 @@ function isLinkLocal(ip: string): boolean {
* works because the check re-runs per hop rather than locking to the first IP.
*
* `responseTimeoutMs` raises undici's own five-minute ceiling for callers that
* legitimately wait longer (see safeFetchLlm). Left unset it keeps undici's
* default, which is what every other admin-configured endpoint wants.
* legitimately wait longer (see safeFetchLlm). Left unset, the caller's own
* signal governs, and a call without one waits DEFAULT_RESPONSE_TIMEOUT_MS for
* the response headers. `maxBytes` caps the body as in SafeFetchOptions.
*/
export async function safeFetchAdminConfigured(
url: string,
init?: RequestInit,
maxRedirects = 5,
responseTimeoutMs?: number,
maxBytes: number | null = DEFAULT_MAX_RESPONSE_BYTES,
): Promise<Response> {
let currentUrl = url;
let hopInit = init;
const headersTimeoutMs = responseTimeoutMs || init?.signal ? undefined : DEFAULT_RESPONSE_TIMEOUT_MS;
for (let hop = 0; ; hop++) {
let parsed: URL;
@@ -293,14 +296,14 @@ export async function safeFetchAdminConfigured(
throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed');
}
const dispatcher = createOutboundDispatcher(currentUrl, usableIps, true, responseTimeoutMs);
const dispatcher = createOutboundDispatcher(currentUrl, usableIps, true, responseTimeoutMs, headersTimeoutMs);
const response = await fetch(currentUrl, { ...hopInit, redirect: 'manual', dispatcher } as any);
// Only a 3xx WITH a Location header is a redirect we follow; anything else
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
const status = typeof response.status === 'number' ? response.status : 0;
const location = status >= 300 && status < 400 ? (response.headers?.get('location') ?? null) : null;
if (!location) return response;
if (!location) return capResponse(response, maxBytes);
if (hop >= maxRedirects) {
throw new SsrfBlockedError('Too many redirects');
@@ -331,8 +334,13 @@ export async function safeFetchAdminConfigured(
* The ceiling is read once per call rather than per hop, so a redirect chain is
* measured against one value even if the variable changes mid-chain.
*/
export function safeFetchLlm(url: string, init?: RequestInit, maxRedirects = 5): Promise<Response> {
return safeFetchAdminConfigured(url, init, maxRedirects, readEnv().integrations.llmTimeoutMs);
export function safeFetchLlm(
url: string,
init?: RequestInit,
maxRedirects = 5,
maxBytes: number | null = DEFAULT_MAX_RESPONSE_BYTES,
): Promise<Response> {
return safeFetchAdminConfigured(url, init, maxRedirects, readEnv().integrations.llmTimeoutMs, maxBytes);
}
/**
@@ -345,8 +353,86 @@ export class SsrfBlockedError extends Error {
}
}
/**
* Thrown when a guarded response is larger than its cap: up front when the
* declared Content-Length says so, otherwise by the body read that passes it.
*/
export class ResponseTooLargeError extends Error {
constructor(readonly maxBytes: number) {
super(`Response body exceeds ${maxBytes} bytes`);
this.name = 'ResponseTooLargeError';
}
}
/**
* What a guarded response may hold when the caller does not say. Sized for the
* largest JSON a user's own server sends back (an Immich album lists every
* asset it holds), and still small enough that one answer cannot take the
* process's memory with it.
*/
export const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
/**
* How long a guarded request waits for the response headers when the caller
* passes no signal of its own. Only the headers: the body stays under undici's
* idle timeout, so a proxied video that pauses with its player is not cut off.
*/
export const DEFAULT_RESPONSE_TIMEOUT_MS = 30_000;
export interface SafeFetchOptions {
rejectUnauthorized?: boolean;
/**
* A deadline for the whole exchange, body included. Combined with the init's
* own signal when there is one. Without either, the request waits
* DEFAULT_RESPONSE_TIMEOUT_MS for the response headers.
*/
timeoutMs?: number;
/**
* The most bytes the body may hold; DEFAULT_MAX_RESPONSE_BYTES when left out.
* `null` lifts the cap, for a caller that streams the body on without holding
* it (a proxied original) or counts the bytes itself.
*/
maxBytes?: number | null;
}
/** The caller's signal, joined with the deadline from `timeoutMs` when one is asked for. */
function deadlineSignal(signal: AbortSignal | null | undefined, timeoutMs: number | undefined): AbortSignal | undefined {
if (!timeoutMs) return signal ?? undefined;
const deadline = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, deadline]) : deadline;
}
/**
* The response with its body held to `maxBytes`. A declared length over the cap
* is refused before anything is read; a body without one (chunked, compressed)
* errors as soon as the bytes passing through cross it, so `json()`,
* `arrayBuffer()` or a pipe reject instead of buffering the rest. Anything that
* is not a platform Response with a stream (a test stub) is returned as it is.
*/
export function capResponse(response: Response, maxBytes: number | null): Response {
if (maxBytes === null) return response;
const declared = Number(response.headers?.get?.('content-length') ?? 0);
if (declared > maxBytes) {
void response.body?.cancel().catch(() => {});
throw new ResponseTooLargeError(maxBytes);
}
const body = response.body;
if (!(response instanceof Response) || !body || typeof body.pipeThrough !== 'function') return response;
let received = 0;
const counted = body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
received += chunk.byteLength;
if (received > maxBytes) controller.error(new ResponseTooLargeError(maxBytes));
else controller.enqueue(chunk);
},
}),
);
const capped = new Response(counted, { status: response.status, statusText: response.statusText, headers: response.headers });
// A constructed Response has an empty url; callers read the final hop from it.
Object.defineProperty(capped, 'url', { value: response.url });
Object.defineProperty(capped, 'redirected', { value: response.redirected });
return capped;
}
/**
@@ -359,8 +445,9 @@ export interface SafeFetchOptions {
* applies — only the TLS certificate check is relaxed.
*
* Redirects are followed through safeFetchFollow, so every hop is re-checked and
* re-pinned. It used to hand the platform a `redirect: 'follow'` with a
* dispatcher pinned to the FIRST hop only — the same shape as
* re-pinned, with safeFetchFollow's defaults: a body cap and, for a call without
* a signal, a bounded wait for the headers. It used to hand the platform a
* `redirect: 'follow'` with a dispatcher pinned to the FIRST hop only — the same shape as
* GHSA-8mw6-xphx-886m, and pinning does not help there because Node skips the
* pinned lookup for an IP-literal host. Sixteen callers ride on this, several
* with a URL out of a per-user setting (Immich, Synology, AirTrail), and one
@@ -467,7 +554,8 @@ export interface SafeFetchFollowOptions extends SafeFetchOptions {
*
* The returned Response is the first non-redirect response (or the last redirect
* if the hop limit is reached). `response.url` reflects the final hop so callers
* relying on the resolved URL keep working.
* relying on the resolved URL keep working. Its body is capped and the wait is
* bounded as SafeFetchOptions describes.
*/
export async function safeFetchFollow(
url: string,
@@ -477,9 +565,13 @@ export async function safeFetchFollow(
const maxRedirects = options?.maxRedirects ?? 5;
const rejectUnauthorized = options?.rejectUnauthorized ?? true;
const bypassInternalIpAllowed = options?.bypassInternalIpAllowed ?? false;
const maxBytes = options?.maxBytes === undefined ? DEFAULT_MAX_RESPONSE_BYTES : options.maxBytes;
// One signal for the whole chain, so a redirect does not restart the deadline.
const signal = deadlineSignal(init?.signal, options?.timeoutMs);
const headersTimeoutMs = signal ? undefined : DEFAULT_RESPONSE_TIMEOUT_MS;
let currentUrl = url;
let hopInit = init;
let hopInit = signal ? { ...init, signal } : init;
for (let hop = 0; ; hop++) {
const ssrf = await checkSsrf(currentUrl, bypassInternalIpAllowed);
@@ -487,7 +579,13 @@ export async function safeFetchFollow(
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
}
const dispatcher = createOutboundDispatcher(currentUrl, ssrf.resolvedIps ?? [ssrf.resolvedIp!], rejectUnauthorized);
const dispatcher = createOutboundDispatcher(
currentUrl,
ssrf.resolvedIps ?? [ssrf.resolvedIp!],
rejectUnauthorized,
undefined,
headersTimeoutMs,
);
const response = await fetch(currentUrl, {
...hopInit,
redirect: 'manual',
@@ -500,7 +598,7 @@ export async function safeFetchFollow(
const isRedirectStatus = status >= 300 && status < 400;
const location = isRedirectStatus ? (response.headers?.get('location') ?? null) : null;
if (!location) {
return response;
return capResponse(response, maxBytes);
}
if (hop >= maxRedirects) {
@@ -561,16 +659,31 @@ export function createOutboundDispatcher(
resolved: string | readonly string[],
rejectUnauthorized = true,
responseTimeoutMs?: number,
headersTimeoutMs?: number,
): Dispatcher {
const proxy = proxyFor(url);
if (!proxy) return createPinnedDispatcher(resolved, rejectUnauthorized, responseTimeoutMs);
if (!proxy) return createPinnedDispatcher(resolved, rejectUnauthorized, responseTimeoutMs, headersTimeoutMs);
return new ProxyAgent({
uri: proxy,
requestTls: { rejectUnauthorized },
...(responseTimeoutMs ? { headersTimeout: responseTimeoutMs, bodyTimeout: responseTimeoutMs } : {}),
...dispatcherTimeouts(responseTimeoutMs, headersTimeoutMs),
});
}
/**
* undici's timeouts for one dispatcher. `responseTimeoutMs` bounds both the
* wait for the headers and each pause in the body; `headersTimeoutMs` bounds
* the headers only. Neither leaves undici its five-minute defaults.
*/
function dispatcherTimeouts(
responseTimeoutMs?: number,
headersTimeoutMs?: number,
): { headersTimeout?: number; bodyTimeout?: number } {
if (responseTimeoutMs) return { headersTimeout: responseTimeoutMs, bodyTimeout: responseTimeoutMs };
if (headersTimeoutMs) return { headersTimeout: headersTimeoutMs };
return {};
}
/**
* Returns an undici Agent whose connect.lookup is pinned to the already-validated
* addresses. This prevents DNS rebinding (TOCTOU) by ensuring the outbound
@@ -586,6 +699,7 @@ export function createPinnedDispatcher(
resolved: string | readonly string[],
rejectUnauthorized = true,
responseTimeoutMs?: number,
headersTimeoutMs?: number,
): Agent {
const addresses = (typeof resolved === 'string' ? [resolved] : [...resolved]).map((address) => ({
address,
@@ -595,9 +709,7 @@ export function createPinnedDispatcher(
// undici caps the wait for response headers at 5 minutes by default, and
// that cap is invisible from the call site: an AbortController set to
// fifteen still dies at five.
...(responseTimeoutMs
? { headersTimeout: responseTimeoutMs, bodyTimeout: responseTimeoutMs }
: {}),
...dispatcherTimeouts(responseTimeoutMs, headersTimeoutMs),
connect: {
rejectUnauthorized,
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
@@ -1228,13 +1228,13 @@ describe('Immich self-signed certificate switch (#2475)', () => {
testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 1 WHERE id = ?').run(user.id);
await hitEveryPath();
expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3);
for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toEqual(LAX);
for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toMatchObject(LAX);
vi.mocked(safeFetch).mockClear();
testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 0 WHERE id = ?').run(user.id);
await hitEveryPath();
expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3);
for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toEqual(STRICT);
for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toMatchObject(STRICT);
});
it('IMMICH-105: POST /test probes with the switch in the form, and refuses a value that is not a boolean', async () => {
@@ -1248,11 +1248,11 @@ describe('Immich self-signed certificate switch (#2475)', () => {
const lax = await test(true);
expect(lax.status).toBe(200);
expect(lax.body.connected).toBe(true);
expect(vi.mocked(safeFetch).mock.calls[0][2]).toEqual(LAX);
expect(vi.mocked(safeFetch).mock.calls[0][2]).toMatchObject(LAX);
const strict = await test(false);
expect(strict.status).toBe(200);
expect(vi.mocked(safeFetch).mock.calls[1][2]).toEqual(STRICT);
expect(vi.mocked(safeFetch).mock.calls[1][2]).toMatchObject(STRICT);
// Only a real boolean: the form has always sent one, and anything else
// must not be read as either answer.
@@ -0,0 +1,92 @@
import tsParser from '@typescript-eslint/parser';
import { ESLint, Linter } from 'eslint';
import path from 'path';
import { beforeAll, describe, expect, it } from 'vitest';
// The outbound-timeout guard is no-restricted-syntax selectors in
// eslint.config.mjs, and a selector that stops matching fails silently: the
// lint stays green and nothing is checked. This asks ESLint for the options it
// really applies to a given path (so the flat-config blocks and their ignores
// are part of what is tested) and runs those selectors over small snippets.
const serverRoot = path.resolve(__dirname, '../../..');
const eslint = new ESLint({ cwd: serverRoot });
type RuleEntry = [string, ...unknown[]];
async function selectorsFor(file: string): Promise<unknown[]> {
const config = (await eslint.calculateConfigForFile(path.join(serverRoot, file))) as {
rules?: Record<string, RuleEntry>;
};
const entry = config.rules?.['no-restricted-syntax'];
if (!entry) throw new Error(`no-restricted-syntax is not configured for ${file}`);
return entry.slice(1);
}
function messages(options: unknown[], code: string): string[] {
const linter = new Linter({ configType: 'flat' });
const result = linter.verify(
code,
[
{
files: ['**/*.ts'],
languageOptions: { parser: tsParser },
rules: { 'no-restricted-syntax': ['error', ...(options as object[])] },
},
],
'probe.ts',
);
return result.map((m) => m.message);
}
const PRELUDE = `
declare function fetch(url: string, init?: object): Promise<unknown>;
declare function safeFetch(url: string, init?: object, options?: object): Promise<unknown>;
declare function safeFetchFollow(url: string, init?: object, options?: object): Promise<unknown>;
declare function safeFetchAdminConfigured(url: string, init?: object): Promise<unknown>;
declare function safeFetchLlm(url: string, init?: object): Promise<unknown>;
declare const url: string;
declare const init: object;
declare const signal: AbortSignal;
`;
const TIMEOUT = 'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }.';
describe('outbound fetch timeout selectors', () => {
let options: unknown[];
beforeAll(async () => {
options = await selectorsFor('src/nest/memories/probe.service.ts');
});
it.each([
['fetch(url)'],
["fetch(url, { method: 'POST' })"],
['safeFetch(url)'],
['safeFetch(url, undefined, { rejectUnauthorized: false })'],
['safeFetchFollow(url, undefined, { bypassInternalIpAllowed: true })'],
["safeFetchFollow(url, { headers: { a: 'b' } })"],
['safeFetchAdminConfigured(url, {})'],
])('flags %s', (call) => {
expect(messages(options, `${PRELUDE}\nvoid ${call};`)).toEqual([TIMEOUT]);
});
it.each([
['fetch(url, init)'],
['fetch(url, { signal })'],
['safeFetch(url, { signal: AbortSignal.timeout(5000) })'],
['safeFetch(url, { ...init })'],
['safeFetch(url, init, { rejectUnauthorized: false })'],
// The options object in third place carries no signal and must not be mistaken for the init.
['safeFetchFollow(url, { signal }, { bypassInternalIpAllowed: true })'],
// safeFetchLlm takes its deadline from LLM_TIMEOUT_MS inside the wrapper.
["safeFetchLlm(url, { method: 'POST' })"],
])('accepts %s', (call) => {
expect(messages(options, `${PRELUDE}\nvoid ${call};`)).toEqual([]);
});
it('holds in the repositories too, whose block restates the selectors', async () => {
const repo = await selectorsFor('src/db/repositories/Probe.repository.ts');
expect(messages(repo, `${PRELUDE}\nvoid safeFetch(url, undefined, {});`)).toEqual([TIMEOUT]);
});
});
@@ -323,8 +323,8 @@ describe('PaperlessClient: the request it builds', () => {
on('GET /api/ui_settings/', reply({ user: { username: 'admin' }, settings: {}, permissions: [] }));
await provider.probe(CONN);
await provider.probe({ ...CONN, allowInsecureTls: true });
expect(calls[0]?.options).toEqual({ rejectUnauthorized: true });
expect(calls[1]?.options).toEqual({ rejectUnauthorized: false });
expect(calls[0]?.options).toEqual({ rejectUnauthorized: true, maxBytes: null });
expect(calls[1]?.options).toEqual({ rejectUnauthorized: false, maxBytes: null });
expect(calls[0]?.init.signal).toBeInstanceOf(AbortSignal);
});
@@ -257,7 +257,7 @@ describe('probe', () => {
it('forwards the self-signed switch to the fetch layer and nowhere else', async () => {
answerWith(reply({ body: { tags: [] } }), reply({ body: { organizations: [] } }));
await provider.probe({ ...CONN, allowInsecureTls: true });
expect(calls[0].options).toEqual({ rejectUnauthorized: false });
expect(calls[0].options).toEqual({ rejectUnauthorized: false, maxBytes: null });
});
it('reports a missing key without touching the network', async () => {
@@ -205,13 +205,13 @@ describe('providerFetch', () => {
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 });
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 });
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 () => {
@@ -898,14 +898,14 @@ describe('self-signed certificates', () => {
await exerciseEveryPath(USER);
expect(safeFetch).toHaveBeenCalledTimes(EVERY_PATH_CALLS);
for (const call of safeFetch.mock.calls) expect(call[2]).toEqual(LAX);
for (const call of safeFetch.mock.calls) expect(call[2]).toMatchObject(LAX);
});
it('IMMICH-TLS-003: with the switch off, every request keeps the certificate check', async () => {
await exerciseEveryPath(USER);
expect(safeFetch).toHaveBeenCalledTimes(EVERY_PATH_CALLS);
for (const call of safeFetch.mock.calls) expect(call[2]).toEqual(STRICT);
for (const call of safeFetch.mock.calls) expect(call[2]).toMatchObject(STRICT);
});
it('IMMICH-TLS-004: a shared photo follows the switch of its owner, not of the viewer', async () => {
@@ -922,14 +922,14 @@ describe('self-signed certificates', () => {
expect(safeFetch).toHaveBeenCalledTimes(3);
for (const call of safeFetch.mock.calls) {
expect(call[0]).toContain('https://owner.test');
expect(call[2]).toEqual(LAX);
expect(call[2]).toMatchObject(LAX);
}
safeFetch.mockClear();
seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1);
seedUser(20, 'https://owner.test', 'owner-key', 0, 0);
await svc.getAssetInfo(USER, 'a1', 20);
expect(safeFetch.mock.calls[0][2]).toEqual(STRICT);
expect(safeFetch.mock.calls[0][2]).toMatchObject(STRICT);
});
it('IMMICH-TLS-005: saving without the switch keeps it, saving with it sets it, disconnecting clears it', async () => {
@@ -279,11 +279,11 @@ describe('pipeAsset fetch options (#1611)', () => {
expect(mockSafeFetch).toHaveBeenCalledWith(
'https://example.com/asset',
expect.anything(),
{ rejectUnauthorized: false },
{ rejectUnauthorized: false, maxBytes: null },
);
});
it('MEM-HELPERS-022: omitting fetchOptions leaves safeFetch options undefined', async () => {
it('MEM-HELPERS-022: omitting fetchOptions only lifts the body cap, since the asset is streamed through', async () => {
mockSafeFetch.mockResolvedValue({
status: 200,
headers: { get: vi.fn(() => null) },
@@ -293,7 +293,7 @@ describe('pipeAsset fetch options (#1611)', () => {
await pipeAsset('https://example.com/asset', res);
expect(mockSafeFetch.mock.calls[0][2]).toBeUndefined();
expect(mockSafeFetch.mock.calls[0][2]).toEqual({ maxBytes: null });
});
it('MEM-HELPERS-023: logs the underlying error when responding 500', async () => {
@@ -292,6 +292,18 @@ describe('collectPhotos', () => {
expect((await make(maps, cacheStub()).enrich(1, REQ)).photos).toEqual([]);
});
it('ENRICH-010b: downloads a third-party picture with a deadline and a size cap', async () => {
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
await make(maps, cacheStub()).enrich(1, REQ);
expect(mockSafeFetchFollow).toHaveBeenCalled();
for (const [, init, options] of mockSafeFetchFollow.mock.calls as unknown as [string, RequestInit, unknown][]) {
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(options).toEqual({ bypassInternalIpAllowed: true, maxBytes: 8 * 1024 * 1024 });
}
});
it('ENRICH-011: drops a candidate the cache refuses to store', async () => {
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
const cache = cacheStub({ put: vi.fn(async () => { throw new Error('disk full'); }) });
@@ -458,4 +458,22 @@ describe('fetchSynologyThumbnailBytes', () => {
expect(result).toHaveProperty('error');
expect(safeFetch).not.toHaveBeenCalled();
});
it('SYNO-U081: bounds the thumbnail read in time and size, on a host the user configured', async () => {
seedUser(7);
safeFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'image/jpeg' },
arrayBuffer: async () => new Uint8Array([1, 2]).buffer,
});
const result = await svc.fetchSynologyThumbnailBytes(7, 7, '101_1633659236');
expect(result).toEqual({ bytes: Buffer.from([1, 2]), contentType: 'image/jpeg' });
const [url, init, options] = safeFetch.mock.calls.at(-1)!;
expect(String(url)).toContain('SYNO.Foto.Thumbnail');
expect((init as RequestInit).signal).toBeInstanceOf(AbortSignal);
expect(options).toEqual({ rejectUnauthorized: false, maxBytes: 8 * 1024 * 1024 });
});
});
@@ -29,7 +29,12 @@ const { readEnvMock } = vi.hoisted(() => ({
vi.mock('../../../src/app-config', () => ({ readEnv: readEnvMock }));
import dns from 'dns/promises';
import { createPinnedDispatcher, safeFetchLlm, safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard';
import {
createPinnedDispatcher,
DEFAULT_RESPONSE_TIMEOUT_MS,
safeFetchAdminConfigured,
safeFetchLlm,
} from '../../../src/utils/ssrfGuard';
const mockLookup = vi.mocked(dns.lookup);
@@ -91,12 +96,21 @@ describe('the ceiling belongs to the model lane only', () => {
expect(optionsOf().bodyTimeout).toBe(120_000);
});
it('safeFetchAdminConfigured keeps undici default — OIDC and plugin OAuth ride this lane', async () => {
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).toBeUndefined();
expect(optionsOf().headersTimeout).toBe(DEFAULT_RESPONSE_TIMEOUT_MS);
expect(optionsOf().bodyTimeout).toBeUndefined();
});
@@ -0,0 +1,193 @@
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);
});
});