From 248a658713431dbd2ab8a9d2245270960a44af44 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 15:17:20 -0700 Subject: [PATCH] fix(cctv): serve only live feeds on /media /api/cctv/media relayed still-image cameras with an ordinary fetch: it followed redirects to any host, passed through any content type and had no body cap. Stills are served by /api/cctv/frame, which keeps redirects on the registered origin, accepts only image/* and caps the body, and no client requests a still through /media. /media now answers 404 for anything but HLS, MP4/WebM and MJPEG, before any upstream request. --- CHANGELOG.md | 10 ++ docs/CURRENT-STATE.md | 3 + server/providers/cctv.js | 17 +- src/data/cctvMediaFeedType.test.mjs | 270 ++++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 src/data/cctvMediaFeedType.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9332ca6..8d7d4fdc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +- Refuse still-image cameras on the CCTV media relay. `/api/cctv/media` is the + live-feed route, but it also relayed any registered still camera through an + ordinary fetch that followed redirects to any host, passed through whatever + content type came back and had no cap on the body, bypassing every protection + `/api/cctv/frame` applies to the same URL. Only HLS, MP4, WebM and MJPEG + feeds are relayed now; a still camera, or an unknown id, gets a JSON 404 + without an upstream request and is served only through `/api/cctv/frame`. + The client already loaded stills through `/frame`, so nothing it requests + changes. + - Stop one retired bikeshare feed from reporting the whole layer as failed. City feeds are fetched independently, but failure was recorded in a single flag that only a successful *activation* cleared — and an already-loaded city diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4774b5c80..d1b4c3a3b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1162,6 +1162,9 @@ still waiting to drain reschedules that deadline, so a slow client does not read as a stalled upstream. Error responses are cancelled. Buffered snapshots have a 16 MiB streaming cap; an oversized image remains an upstream miss and uses the normal fallback chain. The existing declared media size ceiling remains 64 MiB. +`/api/cctv/media` relays live feeds (HLS, MP4, WebM, MJPEG) only; a still-image +camera is refused there with a JSON 404 and is served solely through +`/api/cctv/frame` and its same-origin, `image/*`-only, size-capped fetch. ## GBFS upstream bounds diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 9cf3b0c47..b4c8673f1 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -28,7 +28,7 @@ export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; * GET /api/cctv/sources — list all registered camera sources * GET /api/cctv/health — per-camera health/status report * GET /api/cctv/stream/:id — stream info (feedType, URLs) for a camera - * GET /api/cctv/media/:id — proxy live video/image media from upstream + * GET /api/cctv/media/:id — proxy live video media from upstream * GET /api/cctv/frame/:id — single frame with fallback chain * * @returns {import('vite').Plugin} @@ -214,6 +214,21 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { const source = sourceById.get(cameraId); const mediaUrl = source?.url || ''; const feedType = normalizeFeedType(source?.feedType || 'image'); + // Live feeds only. A still camera is served by /frame, whose fetch + // keeps redirects on the registered origin, accepts only image/* and + // caps the body; relaying it here would bypass all three. + if (!isVideoFeedType(feedType) && feedType !== 'mjpeg') { + res.writeHead(404, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'No live media for this camera; use the frame endpoint', + }), + ); + return; + } const leaseId = url.searchParams.get('lease'); if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) { res.writeHead(400); diff --git a/src/data/cctvMediaFeedType.test.mjs b/src/data/cctvMediaFeedType.test.mjs new file mode 100644 index 000000000..5791e433b --- /dev/null +++ b/src/data/cctvMediaFeedType.test.mjs @@ -0,0 +1,270 @@ +// /api/cctv/media is the live-feed relay. A still camera used to be relayed +// through it as well, by an ordinary redirect-following fetch with no content +// type check and no body cap, which side-stepped everything /api/cctv/frame +// enforces for the same URL. These pin that a still is refused there and only +// reachable through /frame, while live feeds keep their relay. +// +// Run with: npm test (node --test) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { cctvProxy } from '../../server/providers/cctv.js'; + +const STILL = { + id: 'still-fixture', + name: 'Still fixture', + lat: 30.27, + lon: -97.74, + feedType: 'image', + url: 'https://cams.upstream.invalid/still.jpg', +}; +// 'jpg' normalizes to 'image'; the alias must not reopen the relay. +const STILL_ALIAS = { + ...STILL, + id: 'still-alias', + feedType: 'jpg', + url: 'https://cams.upstream.invalid/alias.jpg', +}; +const VIDEO = { + id: 'video-fixture', + name: 'Video fixture', + lat: 30.27, + lon: -97.74, + feedType: 'video', + url: 'https://cams.upstream.invalid/live.mp4', +}; +const MJPEG = { + id: 'mjpeg-fixture', + name: 'MJPEG fixture', + lat: 30.27, + lon: -97.74, + feedType: 'mjpeg', + url: 'https://cams.upstream.invalid/live.mjpg', +}; + +/** A minimal upstream Response with a streaming body. */ +function upstreamResponse({ status = 200, headers = {}, body = 'payload' }) { + const store = new Map( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null }, + body: Readable.toWeb(Readable.from([Buffer.from(String(body))])), + }; +} + +/** Collects what a handler wrote, the way a Node ServerResponse would. */ +function recordingResponse() { + const chunks = []; + let finished; + const done = new Promise((resolve) => { + finished = resolve; + }); + const res = { + statusCode: 0, + headers: {}, + writableEnded: false, + body: '', + writeHead(status, headers) { + res.statusCode = status; + res.headers = headers || {}; + }, + write(chunk) { + chunks.push(Buffer.from(chunk)); + return true; + }, + end(chunk) { + if (chunk) chunks.push(Buffer.from(chunk)); + res.writableEnded = true; + res.body = Buffer.concat(chunks).toString('utf8'); + finished(); + }, + on() {}, + once() {}, + emit() {}, + destroy() {}, + }; + return { res, done }; +} + +/** + * Mount the real provider over the fixture cameras and answer every upstream + * request from the supplied function. + */ +function mount(t, respond) { + const before = { + json: process.env.CCTV_SOURCES_JSON, + file: process.env.CCTV_SOURCES_FILE, + austin: process.env.CCTV_FORCE_AUSTIN, + serverKey: process.env.GOOGLE_MAPS_SERVER_API_KEY, + browserKey: process.env.GOOGLE_MAPS_API_KEY, + }; + process.env.CCTV_SOURCES_JSON = JSON.stringify([ + STILL, + STILL_ALIAS, + VIDEO, + MJPEG, + ]); + process.env.CCTV_SOURCES_FILE = 'absent-source-file.json'; + process.env.CCTV_FORCE_AUSTIN = '0'; + // No Street View key: a /frame miss lands on the synthetic billboard rather + // than a network call this file does not model. + delete process.env.GOOGLE_MAPS_SERVER_API_KEY; + delete process.env.GOOGLE_MAPS_API_KEY; + const nativeFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (url, init = {}) => { + requests.push({ url: String(url), redirect: init.redirect }); + return respond({ url: String(url), init }); + }; + t.after(() => { + globalThis.fetch = nativeFetch; + for (const [name, value] of [ + ['CCTV_SOURCES_JSON', before.json], + ['CCTV_SOURCES_FILE', before.file], + ['CCTV_FORCE_AUSTIN', before.austin], + ['GOOGLE_MAPS_SERVER_API_KEY', before.serverKey], + ['GOOGLE_MAPS_API_KEY', before.browserKey], + ]) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + let handler = null; + cctvProxy().configureServer({ + middlewares: { + use: (_route, fn) => { + handler = fn; + }, + }, + }); + assert.ok(handler, 'the CCTV provider must mount a middleware'); + + const call = async (path) => { + const { res, done } = recordingResponse(); + await handler({ url: path, headers: {}, method: 'GET', on() {} }, res); + await done; + return res; + }; + const health = async (id) => { + const res = await call('/health'); + return JSON.parse(res.body).cameras.find((entry) => entry.id === id); + }; + return { call, health, requests }; +} + +test('a still camera is refused by /media without any upstream request', async (t) => { + // Everything the old relay would have done with this camera: redirect to a + // private address, then answer with HTML. + const app = mount(t, ({ url }) => + url.includes('169.254.169.254') + ? upstreamResponse({ + headers: { 'content-type': 'text/html' }, + body: 'metadata', + }) + : upstreamResponse({ + status: 302, + headers: { location: 'http://169.254.169.254/latest/meta-data/' }, + body: '', + }), + ); + for (const camera of [STILL, STILL_ALIAS]) { + const res = await app.call(`/media/${camera.id}`); + assert.equal(res.statusCode, 404, camera.id); + assert.equal(res.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(res.body), { + error: 'No live media for this camera; use the frame endpoint', + }); + assert.doesNotMatch(res.body, /metadata/); + // Being asked on the wrong route is not a camera fault. + assert.equal(await app.health(camera.id), undefined); + } + assert.deepEqual(app.requests, [], 'a still camera reached its upstream'); +}); + +test('an unregistered camera id is refused by /media the same way', async (t) => { + const app = mount(t, () => { + throw new Error('an unknown camera must not fetch'); + }); + const res = await app.call('/media/not-a-camera'); + assert.equal(res.statusCode, 404); + assert.equal(app.requests.length, 0); +}); + +test('a video camera is still relayed by /media', async (t) => { + const app = mount(t, () => + upstreamResponse({ + headers: { 'content-type': 'video/mp4', 'content-length': '5' }, + body: 'video', + }), + ); + const res = await app.call(`/media/${VIDEO.id}`); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['Content-Type'], 'video/mp4'); + assert.equal(res.headers['X-CCTV-Source'], 'live-media'); + assert.equal(res.body, 'video'); + assert.deepEqual( + app.requests.map(({ url }) => url), + [VIDEO.url], + ); + assert.equal((await app.health(VIDEO.id)).status, 'ok'); +}); + +test('an MJPEG camera is still relayed by /media', async (t) => { + const app = mount(t, () => + upstreamResponse({ + headers: { 'content-type': 'multipart/x-mixed-replace; boundary=frame' }, + body: '--frame', + }), + ); + const res = await app.call(`/media/${MJPEG.id}`); + assert.equal(res.statusCode, 200); + assert.match(res.headers['Content-Type'], /^multipart\/x-mixed-replace/); + assert.equal(res.body, '--frame'); + assert.deepEqual( + app.requests.map(({ url }) => url), + [MJPEG.url], + ); +}); + +test('a still camera is still served through /frame with its bounded fetch', async (t) => { + const app = mount(t, () => + upstreamResponse({ + headers: { 'content-type': 'image/jpeg' }, + body: 'jpeg-bytes', + }), + ); + const res = await app.call(`/frame/${STILL.id}`); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['Content-Type'], 'image/jpeg'); + assert.equal(res.headers['X-CCTV-Source'], 'upstream-image'); + assert.equal(res.body, 'jpeg-bytes'); + assert.deepEqual(app.requests, [{ url: STILL.url, redirect: 'manual' }]); +}); + +test('/frame does not follow a still camera off its origin', async (t) => { + // The protection /media used to bypass, shown on the route that keeps it. + const app = mount(t, ({ url }) => + url === STILL.url + ? upstreamResponse({ + status: 302, + headers: { location: 'http://169.254.169.254/latest/meta-data/' }, + body: '', + }) + : upstreamResponse({ + headers: { 'content-type': 'image/jpeg' }, + body: 'private', + }), + ); + const res = await app.call(`/frame/${STILL.id}`); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['X-CCTV-Source'], 'synthetic'); + assert.doesNotMatch(res.body, /private/); + assert.deepEqual( + app.requests.map(({ url }) => url), + [STILL.url], + ); +});