From 7e8c9ac3a53a65d24838dfd43be2daf2607e7380 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:19:55 +0200 Subject: [PATCH] fix(server): give the last six outbound fetches a timeout, and lint for it Overpass, the Wikipedia photo lookup (which holds one of five shared photo slots), the plugin download and registry, and the two API key checks had no signal, so a provider that took the connection and went quiet held the request for undici's 300 s default. ESLint now refuses a fetch() whose init carries no signal. --- server/eslint.config.mjs | 26 +++++++++++++++++++ server/src/nest/auth/user-profile.service.ts | 7 ++++- server/src/nest/maps/maps.service.ts | 10 ++++++- server/src/nest/plugins/install/safe-fetch.ts | 5 +++- .../nest/plugins/registry/registry.service.ts | 2 +- 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 0c7edecc3..e5d4718a0 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -130,6 +130,19 @@ export default tseslint.config( 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) }.', + }, ], }, }, @@ -272,6 +285,19 @@ export default tseslint.config( 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) }.', + }, ], }, }, diff --git a/server/src/nest/auth/user-profile.service.ts b/server/src/nest/auth/user-profile.service.ts index 58fd0781b..bd88302be 100644 --- a/server/src/nest/auth/user-profile.service.ts +++ b/server/src/nest/auth/user-profile.service.ts @@ -21,6 +21,9 @@ import type { AppSettingsRepository } from '../../db/repositories/AppSettings.re import { Users } from '../../db/entities/Users.entity'; import type { UsersRepository, UserApiKeyColumns, UserProfilePatch } from '../../db/repositories/Users.repository'; +/** A key check is a single request to a provider; one that hangs must not hold the settings page. */ +const KEY_CHECK_TIMEOUT_MS = 10_000; + /** * The account a user administers about themselves: display settings, avatar, * the third-party API keys they paste in, and the directory listing other @@ -376,6 +379,7 @@ export class UserProfileService { 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK, }, body: JSON.stringify({ textQuery: 'test' }), + signal: AbortSignal.timeout(KEY_CHECK_TIMEOUT_MS), } ); result.maps = mapsRes.status === 200; @@ -412,7 +416,8 @@ export class UserProfileService { if (openweather_api_key) { try { const weatherRes = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}` + `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}`, + { signal: AbortSignal.timeout(KEY_CHECK_TIMEOUT_MS) }, ); result.weather = weatherRes.status === 200; } catch { diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 551a60b52..c01dcfe50 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -1469,6 +1469,9 @@ export class MapsService { method: 'POST', headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' }, body: `data=${encodeURIComponent(query)}`, + // The query asks Overpass for 5 s; a public instance that is overloaded + // takes the connection and then sits on it. + signal: AbortSignal.timeout(10_000), }); if (!res.ok) return null; const data = (await res.json()) as { elements?: OverpassElement[] }; @@ -1615,7 +1618,12 @@ export class MapsService { pilimit: '1', redirects: '1', }); - const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } }); + const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { + headers: { 'User-Agent': UA }, + // This runs inside one of the few shared photo-fetch slots; a stalled + // answer would hold it for everyone. + signal: AbortSignal.timeout(WIKI_TIMEOUT_MS), + }); if (res.ok) { const data = (await res.json()) as { query?: { pages?: Record } }; const pages = data.query?.pages; diff --git a/server/src/nest/plugins/install/safe-fetch.ts b/server/src/nest/plugins/install/safe-fetch.ts index b38aecf93..c3432d1c5 100644 --- a/server/src/nest/plugins/install/safe-fetch.ts +++ b/server/src/nest/plugins/install/safe-fetch.ts @@ -13,6 +13,8 @@ import { isBlockedIp } from '../runtime/egress-policy'; const MAX_REDIRECTS = 5; const MAX_BYTES = 50 * 1024 * 1024; +/** The whole download, every hop and the body included. */ +const DOWNLOAD_TIMEOUT_MS = 120_000; export class DownloadError extends Error {} @@ -51,9 +53,10 @@ async function assertSafeHost(urlStr: string): Promise { export async function safeDownload(urlStr: string, maxBytes = MAX_BYTES): Promise<{ bytes: Buffer; sha256: string }> { let current = urlStr; + const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS); for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { await assertSafeHost(current); - const resp = await fetch(current, { redirect: 'manual', headers: { 'User-Agent': 'TREK-Server' } }); + const resp = await fetch(current, { redirect: 'manual', headers: { 'User-Agent': 'TREK-Server' }, signal }); if (resp.status >= 300 && resp.status < 400) { const loc = resp.headers.get('location'); if (!loc) throw new DownloadError('redirect without a location'); diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index 2af4f5cf6..26f6fa3c5 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -220,7 +220,7 @@ export class PluginRegistryService { headers['Cache-Control'] = 'no-cache'; headers.Pragma = 'no-cache'; } - const resp = await fetch(url, { headers }); + const resp = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) }); if (!resp.ok) throw new Error(`registry ${resp.status}`); const data = (await resp.json()) as Registry; if (!data || !Array.isArray(data.plugins)) throw new Error('malformed registry');