diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 65f79b332..9dc4bd27d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -194,6 +194,8 @@ "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs", "server/providers/cctv/cap.js", "server/providers/cctv/groundHeights.js", "server/providers/cctv/catalog.js", diff --git a/server/providers/cctv.js b/server/providers/cctv.js index e549753e8..1f1262709 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -6,6 +6,8 @@ import { } from './cctv/normalize.js'; import { buildSyntheticCctvSvg, + cctvLiveMediaType, + cctvRasterImageType, proxyMediaResponse, fetchCctvImageFromUpstream, fetchTxdotSnapshot, @@ -17,6 +19,7 @@ import { CCTV_MAX_SOURCES_CEILING, } from './cctv/constants.js'; import { sanitizeCctvRangeHeader } from './cctv/range.js'; +import { admitSameSite } from './common/same-site.js'; import { createHlsPuller } from './cctv/stream.js'; import { googleServerApiKey } from './places/google-key.js'; export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; @@ -116,8 +119,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' }, signal: AbortSignal.timeout(CCTV_FRAME_FETCH_TIMEOUT_MS), }); - const svType = svResp.headers.get('content-type') || ''; - if (!svResp.ok || !svType.startsWith('image/')) return null; + const svType = cctvRasterImageType(svResp.headers.get('content-type')); + if (!svResp.ok || !svType) { + try { + await svResp.body?.cancel(); + } catch { + /* no-op */ + } + return null; + } return { ok: true, @@ -135,11 +145,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { }); server.middlewares.use('/api/cctv', async (req, res) => { try { + const url = new URL(req.url || '/', 'http://localhost'); + // Refuse cross-site requests before any catalog or upstream work. + if (admitSameSite(req, res)) return; const sources = await getCctvSources(); const sourceById = new Map( sources.map((source) => [source.id, source]), ); - const url = new URL(req.url || '/', 'http://localhost'); if (url.pathname === '/sources') { const body = { @@ -217,8 +229,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { 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. + // keeps redirects on the registered origin, accepts only raster + // images and caps the body; relaying it here would bypass all three. if (!isVideoFeedType(feedType) && feedType !== 'mjpeg') { res.writeHead(404, { 'Content-Type': 'application/json', @@ -382,7 +394,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { } return; } - const contentType = upstream.headers.get('content-type') || ''; if (!upstream.ok) { try { await upstream.body?.cancel(); @@ -407,31 +418,42 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { return; } - if ( - isVideoFeedType(feedType) && - !( - contentType.startsWith('video/') || - contentType.includes('mpegurl') - ) - ) { + // The relayed type is the feed kind's own, checked before the + // camera counts as healthy and before any byte is relayed. + const contentType = cctvLiveMediaType( + feedType, + upstream.headers.get('content-type'), + ); + if (!contentType) { + try { + await upstream.body?.cancel(); + } catch { + /* already closed */ + } setHealth(cameraId, { status: 'degraded', sourceKind: 'upstream', label: source?.provider || 'Configured source', - message: `Unexpected media type ${contentType || 'unknown'}`, + message: 'Unexpected media type', }); - } else { - setHealth(cameraId, { - status: 'ok', - sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot', - label: source?.provider || 'Configured source', - message: isVideoFeedType(feedType) - ? 'Live stream connected' - : 'Snapshot feed connected', + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', }); + res.end(JSON.stringify({ error: 'Media proxy failed' })); + return; } + setHealth(cameraId, { + status: 'ok', + sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot', + label: source?.provider || 'Configured source', + message: isVideoFeedType(feedType) + ? 'Live stream connected' + : 'Snapshot feed connected', + }); await proxyMediaResponse(res, upstream, { + contentType, sourceHeader: isVideoFeedType(feedType) ? 'live-media' : 'upstream-image', diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 549e6cf06..fdb2a2176 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -93,6 +93,73 @@ export function toReadable(body) { return null; } +/** + * Split a Content-Type value into its lower-case essence and parameters. + * + * @param {string|null|undefined} value + * @returns {{essence:string,params:Map}|null} Null when the + * value is missing or is not a `type/subtype` media type. + */ +export function parseMediaType(value) { + const [head, ...rest] = String(value ?? '').split(';'); + const essence = head.trim().toLowerCase(); + if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) return null; + const params = new Map(); + for (const part of rest) { + const eq = part.indexOf('='); + if (eq <= 0) continue; + const name = part.slice(0, eq).trim().toLowerCase(); + let paramValue = part.slice(eq + 1).trim(); + if (/^".*"$/.test(paramValue)) paramValue = paramValue.slice(1, -1); + if (name && !params.has(name)) params.set(name, paramValue); + } + return { essence, params }; +} + +/** Still-image types a camera frame may be served as. */ +const CCTV_RASTER_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', +]); + +/** + * The canonical type of a raster camera frame, or null for any other type. + * + * @param {string|null|undefined} contentType + * @returns {string|null} + */ +export function cctvRasterImageType(contentType) { + const essence = parseMediaType(contentType)?.essence; + return CCTV_RASTER_IMAGE_TYPES.has(essence) ? essence : null; +} + +/** + * The type to relay a live feed as, or null when the upstream type does not + * match the feed kind. MJPEG keeps its part boundary, without which the body + * cannot be split into frames. + * + * @param {string} feedType - Normalized feed type (mp4, webm or mjpeg). + * @param {string|null|undefined} contentType + * @returns {string|null} + */ +export function cctvLiveMediaType(feedType, contentType) { + const parsed = parseMediaType(contentType); + if (!parsed) return null; + if (feedType === 'mp4' || feedType === 'webm') + return parsed.essence === `video/${feedType}` ? parsed.essence : null; + if (feedType !== 'mjpeg' || parsed.essence !== 'multipart/x-mixed-replace') + return null; + const boundary = parsed.params.get('boundary') || ''; + if (!/^[0-9A-Za-z'()+_,./:=? -]{0,69}[0-9A-Za-z'()+_,./:=?-]$/.test(boundary)) + return null; + return /^[0-9A-Za-z'+_.-]+$/.test(boundary) + ? `multipart/x-mixed-replace; boundary=${boundary}` + : `multipart/x-mixed-replace; boundary="${boundary}"`; +} + /** * Pipe an upstream fetch Response (image or video) to the client HTTP response. * @@ -104,6 +171,8 @@ export function toReadable(body) { * @param {Response} upstream - fetch() Response object. * @param {object} [opts] * @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header. + * @param {string} [opts.contentType] - Validated type to send instead of the + * upstream's own. * @param {number} [opts.idleTimeoutMs=CCTV_MEDIA_IDLE_TIMEOUT_MS] - Silence * allowed between upstream chunks before the stream is released. Injectable * only to keep the deadline unit-testable. @@ -114,10 +183,10 @@ export async function proxyMediaResponse( { sourceHeader = 'upstream', idleTimeoutMs = CCTV_MEDIA_IDLE_TIMEOUT_MS, + contentType = upstream.headers.get('content-type') || + 'application/octet-stream', } = {}, ) { - const contentType = - upstream.headers.get('content-type') || 'application/octet-stream'; const cacheControl = upstream.headers.get('cache-control') || 'no-store'; const contentLength = upstream.headers.get('content-length'); const contentRange = upstream.headers.get('content-range'); @@ -340,7 +409,14 @@ export async function fetchCctvMediaUpstream( if (downstream?.aborted) controller.abort(); else downstream?.addEventListener?.('abort', onDownstreamAbort); try { - return await fetchImpl(url, { headers, signal: controller.signal }); + // Redirects stay on the registered origin, as for frames. + const upstream = await fetchWithinHost( + url, + { headers, signal: controller.signal }, + fetchImpl, + ); + if (!upstream) throw new Error('Media redirect refused'); + return upstream; } finally { clearTimeout(timeoutId); downstream?.removeEventListener?.('abort', onDownstreamAbort); @@ -543,9 +619,15 @@ export async function fetchCctvImageFromUpstream( fetchImpl, ); if (!upstream) return null; - const contentType = upstream.headers.get('content-type') || ''; - if (!upstream.ok || !contentType.startsWith('image/')) { - controller.abort(); + const contentType = cctvRasterImageType( + upstream.headers.get('content-type'), + ); + if (!upstream.ok || !contentType) { + try { + await upstream.body?.cancel(); + } catch { + /* no-op */ + } return null; } const body = await readCappedResponseBytes(upstream, maxBytes); diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index c6e3ba4ec..7594bc1bf 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -236,13 +236,26 @@ export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) { } const upstream = await fetchGbfsUpstream(upstreamUrl.toString()); + // Only JSON is relayed, re-serialized under a fixed type: neither the + // upstream's text nor its declared type reaches the browser. + let payload; + try { + payload = JSON.parse(upstream.body); + } catch { + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'GBFS upstream returned non-JSON' })); + return; + } res.writeHead(upstream.status, { - 'Content-Type': upstream.contentType, + 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': gbfsCacheControl(upstreamUrl.pathname), 'X-GBFS-Upstream': upstreamUrl.hostname, 'X-GBFS-Cache': 'MISS', }); - res.end(upstream.body); + res.end(JSON.stringify(payload)); } catch (error) { if (error?.name === 'AbortError') { res.writeHead(504, { diff --git a/src/data/cctvMediaFeedType.test.mjs b/src/data/cctvMediaFeedType.test.mjs index 5791e433b..532f1ae45 100644 --- a/src/data/cctvMediaFeedType.test.mjs +++ b/src/data/cctvMediaFeedType.test.mjs @@ -33,6 +33,14 @@ const VIDEO = { feedType: 'video', url: 'https://cams.upstream.invalid/live.mp4', }; +const WEBM = { + id: 'webm-fixture', + name: 'WebM fixture', + lat: 30.27, + lon: -97.74, + feedType: 'webm', + url: 'https://cams.upstream.invalid/live.webm', +}; const MJPEG = { id: 'mjpeg-fixture', name: 'MJPEG fixture', @@ -69,7 +77,10 @@ function recordingResponse() { body: '', writeHead(status, headers) { res.statusCode = status; - res.headers = headers || {}; + res.headers = { ...res.headers, ...headers }; + }, + setHeader(name, value) { + res.headers[name] = value; }, write(chunk) { chunks.push(Buffer.from(chunk)); @@ -105,6 +116,7 @@ function mount(t, respond) { STILL, STILL_ALIAS, VIDEO, + WEBM, MJPEG, ]); process.env.CCTV_SOURCES_FILE = 'absent-source-file.json'; @@ -143,9 +155,9 @@ function mount(t, respond) { }); assert.ok(handler, 'the CCTV provider must mount a middleware'); - const call = async (path) => { + const call = async (path, headers = {}) => { const { res, done } = recordingResponse(); - await handler({ url: path, headers: {}, method: 'GET', on() {} }, res); + await handler({ url: path, headers, method: 'GET', on() {} }, res); await done; return res; }; @@ -268,3 +280,195 @@ test('/frame does not follow a still camera off its origin', async (t) => { [STILL.url], ); }); + +/** An upstream Response whose body records whether it was cancelled. */ +function trackedUpstream({ status = 200, headers = {}, body = 'payload' }) { + const state = { cancelled: false }; + const response = new Response( + new ReadableStream( + { + pull(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + cancel() { + state.cancelled = true; + }, + }, + { highWaterMark: 0 }, + ), + { status, headers }, + ); + return { response, state }; +} + +test('/media refuses an upstream type that does not match the feed kind', async (t) => { + let current; + const app = mount(t, () => current.response); + for (const [camera, contentType, body] of [ + [VIDEO, 'text/html', '

x

'], + [VIDEO, 'image/svg+xml', ''], + [VIDEO, 'application/vnd.apple.mpegurl', '#EXTM3U'], + [VIDEO, 'video/webm', 'video'], + [VIDEO, undefined, 'video'], + [WEBM, 'video/mp4', 'video'], + [MJPEG, 'multipart/x-mixed-replace', '--frame'], + [MJPEG, 'multipart/x-mixed-replace; boundary=', '--frame'], + [MJPEG, 'image/jpeg', 'jpeg'], + [MJPEG, 'text/html; boundary=frame', '

x

'], + ]) { + current = trackedUpstream({ + headers: contentType === undefined ? {} : { 'content-type': contentType }, + body, + }); + const label = `${camera.id}: ${contentType}`; + const res = await app.call(`/media/${camera.id}`); + assert.equal(res.statusCode, 502, label); + assert.equal(res.headers['Content-Type'], 'application/json', label); + assert.deepEqual(JSON.parse(res.body), { error: 'Media proxy failed' }); + assert.equal(current.state.cancelled, true, `${label}: upstream cancelled`); + const health = await app.health(camera.id); + assert.equal(health.status, 'degraded', label); + assert.equal(health.message, 'Unexpected media type', label); + } +}); + +test('/media relays each feed kind under its canonical type, including a partial response', async (t) => { + let current; + const app = mount(t, () => current); + for (const [camera, upstream, expected] of [ + [ + VIDEO, + { 'content-type': 'Video/MP4; codecs="avc1.42E01E"' }, + 'video/mp4', + ], + [ + VIDEO, + { + 'content-type': 'video/mp4', + 'content-range': 'bytes 0-4/100', + 'content-length': '5', + 'accept-ranges': 'bytes', + }, + 'video/mp4', + ], + [WEBM, { 'content-type': 'video/webm' }, 'video/webm'], + [ + MJPEG, + { 'content-type': 'multipart/x-mixed-replace;boundary="frame"' }, + 'multipart/x-mixed-replace; boundary=frame', + ], + [ + MJPEG, + { 'content-type': 'multipart/x-mixed-replace; boundary="a b"' }, + 'multipart/x-mixed-replace; boundary="a b"', + ], + ]) { + const status = upstream['content-range'] ? 206 : 200; + current = upstreamResponse({ status, headers: upstream, body: 'bytes' }); + const res = await app.call(`/media/${camera.id}`); + assert.equal(res.statusCode, status, camera.id); + assert.equal(res.headers['Content-Type'], expected); + assert.equal(res.body, 'bytes'); + if (status === 206) + assert.equal(res.headers['Content-Range'], 'bytes 0-4/100'); + assert.equal((await app.health(camera.id)).status, 'ok'); + } +}); + +test('/media does not follow a live camera off its origin', async (t) => { + const app = mount(t, ({ url }) => + url === VIDEO.url + ? upstreamResponse({ + status: 302, + headers: { location: 'https://elsewhere.invalid/live.mp4' }, + body: '', + }) + : upstreamResponse({ + headers: { 'content-type': 'video/mp4' }, + body: 'elsewhere', + }), + ); + const res = await app.call(`/media/${VIDEO.id}`); + assert.equal(res.statusCode, 502); + assert.doesNotMatch(res.body, /elsewhere/); + assert.deepEqual(app.requests, [{ url: VIDEO.url, redirect: 'manual' }]); +}); + +test('every CCTV route refuses cross-site requests and admits the app itself', async (t) => { + const app = mount(t, ({ url }) => + upstreamResponse({ + headers: { + 'content-type': url === VIDEO.url ? 'video/mp4' : 'image/jpeg', + }, + body: 'bytes', + }), + ); + const host = 'localhost:5173'; + const paths = [ + '/sources', + '/health', + `/stream/${STILL.id}`, + `/frame/${STILL.id}`, + `/media/${VIDEO.id}`, + ]; + for (const headers of [ + { host, 'sec-fetch-site': 'cross-site' }, + { host, 'sec-fetch-site': 'same-site' }, + { host, origin: 'https://elsewhere.example' }, + { host, origin: 'null' }, + ]) + for (const path of paths) { + const res = await app.call(path, headers); + assert.equal(res.statusCode, 403, `${path} ${JSON.stringify(headers)}`); + } + assert.deepEqual(app.requests, [], 'a refused request reached an upstream'); + for (const headers of [ + { host, 'sec-fetch-site': 'same-origin' }, + { host, origin: `http://${host}` }, + {}, + ]) { + const frame = await app.call(`/frame/${STILL.id}`, headers); + assert.equal(frame.statusCode, 200); + assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image'); + const media = await app.call(`/media/${VIDEO.id}`, headers); + assert.equal(media.statusCode, 200); + assert.equal(media.headers['Content-Type'], 'video/mp4'); + } +}); + +test('/frame falls back past a non-raster upstream and Street View frame', async (t) => { + let streetViewType; + const app = mount(t, ({ url }) => + url.startsWith('https://maps.googleapis.com/') + ? trackedUpstream({ + headers: + streetViewType === undefined + ? {} + : { 'content-type': streetViewType }, + body: 'street-view', + }).response + : upstreamResponse({ + headers: { 'content-type': 'image/svg+xml' }, + body: '', + }), + ); + process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key'; + for (const type of [ + 'image/svg+xml', + 'text/html', + 'application/xml', + undefined, + ]) { + streetViewType = type; + const res = await app.call(`/frame/${STILL.id}`); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['X-CCTV-Source'], 'synthetic', String(type)); + assert.doesNotMatch(res.body, /street-view/); + } + streetViewType = 'image/JPEG; charset=x'; + const res = await app.call(`/frame/${STILL.id}`); + assert.equal(res.headers['X-CCTV-Source'], 'streetview'); + assert.equal(res.headers['Content-Type'], 'image/jpeg'); + assert.equal(res.body, 'street-view'); +}); diff --git a/src/data/cctvProxy.test.mjs b/src/data/cctvProxy.test.mjs index 54a5d09db..f197c9a97 100644 --- a/src/data/cctvProxy.test.mjs +++ b/src/data/cctvProxy.test.mjs @@ -259,3 +259,57 @@ test('rejected snapshot responses abort the upstream download', async () => { assert.equal(signal.aborted, true); } }); + +test('CCTV upstream frame fetch accepts only raster image types and sends their canonical type', async () => { + const frame = (contentType, body = 'frame') => { + const state = { cancelled: false }; + const headers = contentType === null ? {} : { 'Content-Type': contentType }; + const response = new Response(new ReadableStream({ + pull(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + cancel() { + state.cancelled = true; + }, + }, { highWaterMark: 0 }), { status: 200, headers }); + return { response, state }; + }; + for (const [contentType, canonical] of [ + ['image/jpeg', 'image/jpeg'], + ['image/JPEG; charset=x', 'image/jpeg'], + [' Image/PNG ', 'image/png'], + ['image/gif', 'image/gif'], + ['image/webp;q=1', 'image/webp'], + ['image/avif', 'image/avif'], + ]) { + const { response } = frame(contentType); + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + fetchImpl: async () => response, + }); + assert.equal(result?.contentType, canonical, contentType); + assert.equal(result.body.toString(), 'frame'); + } + for (const [contentType, body] of [ + ['image/svg+xml', ''], + ['Image/SVG+XML; charset=utf-8', ''], + ['text/html', '

x

'], + ['text/html; charset=utf-8', '

x

'], + ['application/xml', ''], + ['text/xml', ''], + [null, 'frame'], + ['', 'frame'], + ['image', 'frame'], + ['image/', 'frame'], + ['image/jpeg/x', 'frame'], + ['image/jpeg, text/html', 'frame'], + ['application/octet-stream', 'frame'], + ]) { + const { response, state } = frame(contentType, body); + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + fetchImpl: async () => response, + }); + assert.equal(result, null, String(contentType)); + assert.equal(state.cancelled, true, `${contentType}: body cancelled`); + } +}); diff --git a/src/data/gbfsProxy.test.mjs b/src/data/gbfsProxy.test.mjs index c95256137..d8c8e5fc3 100644 --- a/src/data/gbfsProxy.test.mjs +++ b/src/data/gbfsProxy.test.mjs @@ -304,3 +304,61 @@ test('GBFS rejects redirects and caps bodies through both server hooks', async ( globalThis.fetch = originalFetch; } }); + +test('GBFS relays only JSON, re-serialized under a fixed type, through both server hooks', async () => { + const { gbfsProxy } = await import('../../server/providers/gbfs.js'); + const originalFetch = globalThis.fetch; + try { + for (const hook of ['configureServer', 'configurePreviewServer']) { + let handler; + gbfsProxy()[hook]({ + middlewares: { + use(_path, callback) { + handler = callback; + }, + }, + }); + const request = async (response) => { + globalThis.fetch = async () => response; + const res = {}; + await handler( + { method: 'GET', url: '/' + encodeURIComponent(STATION_URL) }, + { + writeHead(code, headers) { + Object.assign(res, { status: code, headers }); + }, + end(value) { + res.body = value; + }, + }, + ); + return res; + }; + for (const [contentType, body] of [ + ['text/html', '

x

'], + ['application/json', '

x

'], + ['image/svg+xml', ''], + ['application/json', ''], + ]) { + const res = await request( + new Response(body, { headers: { 'Content-Type': contentType } }), + ); + assert.equal(res.status, 502, `${hook}: ${contentType} ${body}`); + assert.equal(res.headers['Content-Type'], 'application/json'); + assert.equal(typeof JSON.parse(res.body).error, 'string'); + assert.doesNotMatch(res.body, /doctype|svg/); + } + const res = await request( + new Response(' { "data" : { "stations" : [] } } ', { + headers: { 'Content-Type': 'text/html; charset=iso-8859-1' }, + }), + ); + assert.equal(res.status, 200); + assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8'); + assert.equal(res.headers['Cache-Control'], 'no-store'); + assert.equal(res.body, STATION_BODY); + } + } finally { + globalThis.fetch = originalFetch; + } +});