fix(server): bound every outbound fetch

The rule is a timeout, a size cap and boundary validation on anything that
leaves the process, and several call sites had only the timeout. A link
preview, a maps URL resolve, a wiki fallback and a holiday lookup all read
the whole body into memory, so a caller who picks the URL picks how much
memory the server spends - a few concurrent multi-hundred-megabyte responses
is the whole heap.

Two of them also fetched more than their name suggests. resolve-url fell
through to a full page GET for any host once it found no coordinates, which
makes the server a general-purpose outbound proxy wearing its own IP. The
weather URLs took lat/lng straight from the caller into the query string.

readCapped/exceedsDeclaredLength collect the pattern transit.service.ts and
the plugin installer had each grown separately, so there is one of it now.
Callers that only scan the head of a document (og: tags, a coordinate regex)
keep the truncated bytes and find fewer fields; callers that must have the
whole thing reject it.
This commit is contained in:
Maurice
2026-08-23 09:47:22 +02:00
committed by Maurice
parent b163c1e05d
commit 97ebe23d3b
16 changed files with 551 additions and 28 deletions
+19 -3
View File
@@ -9,6 +9,7 @@ import type {
} from '@trek/shared';
import { readEnv, getAppUrl } from '../../app-config';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
@@ -336,6 +337,11 @@ interface GooglePlaceDetails extends GooglePlaceResult {
// Wikimedia is normally well under a second, but a cold TLS handshake from a
// fresh container has been seen at eight. Enrichment answers a live dialog, so
// a slow provider is dropped rather than waited out.
// A Google Maps place page is a few hundred KB; the coordinates sit in the
// embedded map data near the top, so two megabytes is plenty and keeps an
// unbounded body out of memory.
const MAX_MAPS_PAGE_BYTES = 2_000_000;
const WIKI_TIMEOUT_MS = 6000;
// Tighter than the wiki calls, because this one sits at the FRONT of a chain:
@@ -2043,14 +2049,24 @@ export class MapsService {
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
// page body once and parse the coordinates out of the embedded map data.
if (!coords) {
// Only Google's own pages get read; the resolved host is what counts, so a
// short link that lands on maps.google.com still qualifies.
let resolvedHost = '';
try { resolvedHost = new URL(resolvedUrl).hostname; } catch { /* keep the empty host, the branch is skipped */ }
if (!coords && GOOGLE_MAPS_HOSTS.includes(resolvedHost)) {
try {
const pageRes = await followRedirects(resolvedUrl, {
headers: { 'User-Agent': UA },
});
coords = extractCoords(await pageRes.text());
if (!exceedsDeclaredLength(pageRes, MAX_MAPS_PAGE_BYTES)) {
// The map data sits near the top of the document, so a truncated read
// still finds the coordinates; an oversized page degrades to the same
// 400 an unparseable one already produced.
const { text } = await readCappedText(pageRes, MAX_MAPS_PAGE_BYTES);
coords = extractCoords(text);
}
} catch (err) {
if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it
if ((err as { status?: number })?.status === 403) throw err; // SSRF block, surface it
// Otherwise fall through to the not-found error below.
}
}