fix(providers): validate before limiting and tighten capped image reads

Apply shared Places validation, owned image chunks, and response-header deadline tests to the provider modules.
This commit is contained in:
Bilawal Sidhu authored and Sameh Khamis committed 2026-09-12 16:35:11 -07:00
1 parent 59a20e2d18
commit 3d956177ab
8 files changed
+440 -60

No files matched your search

+18 -13
View File
@@ -8,6 +8,7 @@ import {
buildSyntheticCctvSvg,
proxyMediaResponse,
fetchCctvImageFromUpstream,
fetchCctvMediaUpstream,
} from './cctv/media.js';
import { CCTV_FRAME_FETCH_TIMEOUT_MS } from './cctv/constants.js';
import { googleServerApiKey } from './places/google-key.js';
@@ -216,19 +217,16 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
};
const requestRange = req.headers?.range;
if (requestRange) upstreamHeaders.Range = requestRange;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15_000);
let upstream;
try {
upstream = await fetch(mediaUrl, {
headers: upstreamHeaders,
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
const upstream = await fetchCctvMediaUpstream(mediaUrl, {
headers: upstreamHeaders,
});
const contentType = upstream.headers.get('content-type') || '';
if (!upstream.ok) {
try {
await upstream.body?.cancel();
} catch {
/* already closed */
}
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'upstream',
@@ -278,7 +276,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
});
return;
} catch (error) {
const timedOut = error?.name === 'AbortError' || error?.name === 'TimeoutError';
const timedOut =
error?.name === 'AbortError' || error?.name === 'TimeoutError';
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'upstream',
@@ -289,7 +288,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: timedOut ? 'Upstream media timeout' : 'Media proxy failed' }));
res.end(
JSON.stringify({
error: timedOut
? 'Upstream media timeout'
: 'Media proxy failed',
}),
);
return;
}
}
+5
View File
@@ -41,3 +41,8 @@ export const CCTV_FRAME_FETCH_TIMEOUT_MS = 8 * 1000;
/** Maximum buffered snapshot size. */
export const CCTV_FRAME_MAX_BODY_BYTES = 16 * 1024 * 1024;
/** Deadline for upstream response headers; live bodies keep streaming afterward. */
export const CCTV_MEDIA_FETCH_TIMEOUT_MS = 15 * 1000;
/** Declared size ceiling for fixed media responses. */
export const CCTV_MEDIA_MAX_BODY_BYTES = 64 * 1024 * 1024;
+76 -14
View File
@@ -1,6 +1,11 @@
import { Readable } from 'node:stream';
import { hashSeed, escapeXml } from './normalize.js';
import { CCTV_FRAME_FETCH_TIMEOUT_MS, CCTV_FRAME_MAX_BODY_BYTES } from './constants.js';
import {
CCTV_FRAME_FETCH_TIMEOUT_MS,
CCTV_FRAME_MAX_BODY_BYTES,
CCTV_MEDIA_FETCH_TIMEOUT_MS,
CCTV_MEDIA_MAX_BODY_BYTES,
} from './constants.js';
/**
* Generate a synthetic SVG billboard image for a CCTV camera placeholder.
*
@@ -120,10 +125,9 @@ export async function proxyMediaResponse(
// Cheap defense: reject an upstream that DECLARES an oversized fixed body.
// Live MJPEG/HLS streams are unbounded by design and send no content-length,
// so they pipe normally (piping streams to the client, never buffering).
const MEDIA_DECLARED_CAP_BYTES = 64 * 1024 * 1024;
if (
Number.isFinite(Number(contentLength)) &&
Number(contentLength) > MEDIA_DECLARED_CAP_BYTES
Number(contentLength) > CCTV_MEDIA_MAX_BODY_BYTES
) {
res.writeHead(502, {
'Content-Type': 'application/json',
@@ -169,29 +173,87 @@ export async function proxyMediaResponse(
async function readCappedResponseBytes(upstream, maxBytes) {
const declared = Number(upstream.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try { await upstream.body?.cancel(); } catch { /* no-op */ }
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
return null;
}
if (!upstream.body || typeof upstream.body[Symbol.asyncIterator] !== 'function') {
const buffered = Buffer.from(await upstream.arrayBuffer());
return buffered.length > maxBytes ? null : buffered;
}
if (!upstream.body) return null;
const chunks = [];
let total = 0;
for await (const chunk of upstream.body) {
total += chunk.length;
if (total > maxBytes) {
try { await upstream.body.cancel(); } catch { /* no-op */ }
// Every retained chunk is an OWNED copy: a chunk can be a small view over a
// much larger backing ArrayBuffer, and keeping the view would retain that
// whole allocation while the byte accounting only counted the view.
const keep = (chunk) => {
total += chunk.byteLength;
if (total > maxBytes) return false;
chunks.push(Buffer.from(chunk));
return true;
};
if (typeof upstream.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of upstream.body) {
if (!keep(chunk)) {
try {
await upstream.body.cancel();
} catch {
/* no-op */
}
return null;
}
}
return Buffer.concat(chunks, total);
}
// No async iterator: stream through a reader so the cap still applies while
// reading. A body that cannot be streamed at all is refused rather than
// buffered uncapped — the helper's whole contract is the cap.
const reader =
typeof upstream.body.getReader === 'function'
? upstream.body.getReader()
: null;
if (!reader) return null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!keep(value)) {
try {
await reader.cancel();
} catch {
/* no-op */
}
return null;
}
chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
}
return Buffer.concat(chunks, total);
}
export async function fetchCctvMediaUpstream(
url,
{
headers = {},
fetchImpl = fetch,
timeoutMs = CCTV_MEDIA_FETCH_TIMEOUT_MS,
} = {},
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchImpl(url, { headers, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
export async function fetchCctvImageFromUpstream(
url,
{ fetchImpl = fetch, timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS, maxBytes = CCTV_FRAME_MAX_BODY_BYTES } = {},
{
fetchImpl = fetch,
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
CCTV_MEDIA_FETCH_TIMEOUT_MS,
CCTV_MEDIA_MAX_BODY_BYTES,
} = {},
) {
if (!url || !/^https?:\/\//i.test(url)) return null;
const controller = new AbortController();
+49 -31
View File
@@ -21,6 +21,26 @@ function googleRateLimiter() {
return _googleRateLimiter;
}
export function validatePlacesCoordinates(searchParams) {
const rawLat = searchParams.get('lat');
const rawLon = searchParams.get('lon');
if (rawLat === null || rawLon === null || !rawLat.trim() || !rawLon.trim()) {
return { ok: false, error: 'lat and lon are required' };
}
const latitude = Number(rawLat);
const longitude = Number(rawLon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return { ok: false, error: 'Valid lat and lon are required' };
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return {
ok: false,
error: 'lat must be within [-90, 90] and lon within [-180, 180]',
};
}
return { ok: true, latitude, longitude };
}
/** Nearby place labels and view-biased text search, with request-time key resolution. */
export function googlePlacesContextProxy({
resolveApiKey = googleServerApiKey,
@@ -47,6 +67,16 @@ export function googlePlacesContextProxy({
return;
}
const requestUrl = new URL(req.url || '', 'http://localhost');
const coordinates = validatePlacesCoordinates(requestUrl.searchParams);
if (!coordinates.ok) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: coordinates.error, places: [] }));
return;
}
const { latitude, longitude } = coordinates;
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
// Inlined (not the shared helper) so the 429 body keeps this endpoint's
// `places: []` contract that the client expects on every error response.
@@ -59,24 +89,10 @@ export function googlePlacesContextProxy({
return;
}
const requestUrl = new URL(req.url || '', 'http://localhost');
const latitude = Number(requestUrl.searchParams.get('lat'));
const longitude = Number(requestUrl.searchParams.get('lon'));
const radiusM = Math.max(
25,
Math.min(5000, Number(requestUrl.searchParams.get('radiusM')) || 250),
);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error: 'Valid lat and lon are required',
places: [],
}),
);
return;
}
try {
const response = await fetch(
@@ -160,6 +176,25 @@ export function googlePlacesContextProxy({
return;
}
const requestUrl = new URL(req.url || '', 'http://localhost');
const textQuery = String(requestUrl.searchParams.get('q') || '').trim();
if (!textQuery) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({ error: 'q, lat and lon are required', places: [] }),
);
return;
}
const coordinates = validatePlacesCoordinates(requestUrl.searchParams);
if (!coordinates.ok) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: coordinates.error, places: [] }));
return;
}
const { latitude, longitude } = coordinates;
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
// Inlined (like nearby-places) so the 429 body keeps the `places: []`
// contract the client expects on every error response.
@@ -172,27 +207,10 @@ export function googlePlacesContextProxy({
return;
}
const requestUrl = new URL(req.url || '', 'http://localhost');
const textQuery = String(requestUrl.searchParams.get('q') || '').trim();
const latitude = Number(requestUrl.searchParams.get('lat'));
const longitude = Number(requestUrl.searchParams.get('lon'));
const radiusM = Math.max(
50,
Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000),
);
if (
!textQuery ||
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180
) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({ error: 'q, lat and lon are required', places: [] }),
);
return;
}
try {
const response = await fetch(