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');