mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user