diff --git a/server/CLAUDE.md b/server/CLAUDE.md index e66177a4a..d312b19d8 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -48,7 +48,7 @@ Nest owns everything. Every domain is a DI module under `src/nest//` (`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` + 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. diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index e5d4718a0..6e57b911e 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -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], }, }, { diff --git a/server/src/nest/doc-sync/providers/provider-http.ts b/server/src/nest/doc-sync/providers/provider-http.ts index 0b6f39316..2d510965f 100644 --- a/server/src/nest/doc-sync/providers/provider-http.ts +++ b/server/src/nest/doc-sync/providers/provider-http.ts @@ -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)); diff --git a/server/src/nest/llm-parse/llm-local.service.ts b/server/src/nest/llm-parse/llm-local.service.ts index 188c30ace..96fd46452 100644 --- a/server/src/nest/llm-parse/llm-local.service.ts +++ b/server/src/nest/llm-parse/llm-local.service.ts @@ -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); } diff --git a/server/src/nest/memories/memories.helpers.ts b/server/src/nest/memories/memories.helpers.ts index bc5777514..7da518d51 100644 --- a/server/src/nest/memories/memories.helpers.ts +++ b/server/src/nest/memories/memories.helpers.ts @@ -321,7 +321,9 @@ export function describeFetchFailure(err: unknown): string { */ export async function pipeAsset(url: string, response: Response, headers?: Record, signal?: AbortSignal, defaultCacheControl?: string, fetchOptions?: SafeFetchOptions): Promise { 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); diff --git a/server/src/nest/memories/synology.service.ts b/server/src/nest/memories/synology.service.ts index ccfbceead..e0e16af29 100644 --- a/server/src/nest/memories/synology.service.ts +++ b/server/src/nest/memories/synology.service.ts @@ -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 = { 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()); diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index c7bc04e4f..47753e408 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -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 { 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; diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index ea0a1011c..bf4c7ced0 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -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 { 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 { - 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 { + 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({ + 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, callback: Function) => { diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts index c97b0852f..d839cc164 100644 --- a/server/tests/integration/memories-immich.test.ts +++ b/server/tests/integration/memories-immich.test.ts @@ -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. diff --git a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts new file mode 100644 index 000000000..e7f0047c5 --- /dev/null +++ b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts @@ -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 { + const config = (await eslint.calculateConfigForFile(path.join(serverRoot, file))) as { + rules?: Record; + }; + 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; +declare function safeFetch(url: string, init?: object, options?: object): Promise; +declare function safeFetchFollow(url: string, init?: object, options?: object): Promise; +declare function safeFetchAdminConfigured(url: string, init?: object): Promise; +declare function safeFetchLlm(url: string, init?: object): Promise; +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]); + }); +}); diff --git a/server/tests/unit/nest/doc-sync/paperless.provider.test.ts b/server/tests/unit/nest/doc-sync/paperless.provider.test.ts index 655764165..cfdea782d 100644 --- a/server/tests/unit/nest/doc-sync/paperless.provider.test.ts +++ b/server/tests/unit/nest/doc-sync/paperless.provider.test.ts @@ -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); }); diff --git a/server/tests/unit/nest/doc-sync/papra.provider.test.ts b/server/tests/unit/nest/doc-sync/papra.provider.test.ts index 6dcdd6d5d..0f4d0b651 100644 --- a/server/tests/unit/nest/doc-sync/papra.provider.test.ts +++ b/server/tests/unit/nest/doc-sync/papra.provider.test.ts @@ -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 () => { diff --git a/server/tests/unit/nest/doc-sync/provider-http.test.ts b/server/tests/unit/nest/doc-sync/provider-http.test.ts index 33d21f7b3..1588929e4 100644 --- a/server/tests/unit/nest/doc-sync/provider-http.test.ts +++ b/server/tests/unit/nest/doc-sync/provider-http.test.ts @@ -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 () => { diff --git a/server/tests/unit/nest/immich.service.test.ts b/server/tests/unit/nest/immich.service.test.ts index 0fcb2e556..2a8f4be53 100644 --- a/server/tests/unit/nest/immich.service.test.ts +++ b/server/tests/unit/nest/immich.service.test.ts @@ -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 () => { diff --git a/server/tests/unit/nest/memories-access.service.test.ts b/server/tests/unit/nest/memories-access.service.test.ts index a86f8a5a0..bb5b22b6d 100644 --- a/server/tests/unit/nest/memories-access.service.test.ts +++ b/server/tests/unit/nest/memories-access.service.test.ts @@ -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 () => { diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index f76517e3b..0956721d5 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -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'); }) }); diff --git a/server/tests/unit/nest/synology.service.test.ts b/server/tests/unit/nest/synology.service.test.ts index 689fe43d7..e6780d1d4 100644 --- a/server/tests/unit/nest/synology.service.test.ts +++ b/server/tests/unit/nest/synology.service.test.ts @@ -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 }); + }); }); diff --git a/server/tests/unit/utils/ssrf-llm-timeout.test.ts b/server/tests/unit/utils/ssrf-llm-timeout.test.ts index 210c7fe54..a190c4973 100644 --- a/server/tests/unit/utils/ssrf-llm-timeout.test.ts +++ b/server/tests/unit/utils/ssrf-llm-timeout.test.ts @@ -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(); }); diff --git a/server/tests/unit/utils/ssrf-response-limits.test.ts b/server/tests/unit/utils/ssrf-response-limits.test.ts new file mode 100644 index 000000000..33423cec1 --- /dev/null +++ b/server/tests/unit/utils/ssrf-response-limits.test.ts @@ -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; + +/** A real platform Response, as undici hands it back, with the url it would carry. */ +function upstream(body: ConstructorParameters[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 { + 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); + }); +});