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.
This commit is contained in:
Maurice
2026-10-07 16:19:55 +02:00
parent a8fc93dfde
commit 7e8c9ac3a5
5 changed files with 46 additions and 4 deletions
+26
View File
@@ -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) }.',
},
],
},
},
+6 -1
View File
@@ -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 {
+9 -1
View File
@@ -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<string, { thumbnail?: { source?: string } }> } };
const pages = data.query?.pages;
@@ -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<void> {
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');
@@ -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');