diff --git a/CHANGELOG.md b/CHANGELOG.md index d31b48611..41ba9bee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,24 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Security +- The CCTV media route no longer forwards a client `Range` header to the upstream + camera host as it arrived. A single `bytes=` range is canonicalized and + forwarded, with every accepted form — explicit span, open-ended and suffix — + bounded to 64 MiB, the ceiling the relay already applies to a response that + declares its length. A response that declares no length has no ceiling — live + streamed media, and anything an upstream sends chunked while ignoring the + `Range` — which is unchanged. Multi-range, malformed, inverted, non-`bytes` and + unsafe-integer values are dropped and the request proceeds without a `Range`, + as RFC 7233 §3.1 prescribes; a multi-range value previously invited a + `multipart/byteranges` answer, whose parts nothing here reads. A value carrying + CR or LF made the outbound request throw, and the route recorded the thrown + message — which contains the caller's own string — as that camera's entry in + the health report. A request the browser has stopped waiting for is now + released: whether the viewer leaves while the camera is still answering or + part-way through the picture, the upstream request is cancelled rather than + left running, and neither case marks the camera degraded. Ordinary seeking is + unaffected. Contributed by Maher-Reven (#253). + - Validate configured Google Places coordinates and text queries before rate limiting or upstream requests; preserve the keyless capability response. - Bound CCTV media response headers to 15 seconds and cancel error bodies. diff --git a/SECURITY.md b/SECURITY.md index 20fee1a36..9bf718b74 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,6 +63,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts. - **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio. +- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Response-size caps and timeouts** on proxied responses. - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5f27f229c..41dfb629b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -612,5 +612,7 @@ "src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/records.test.mjs", "src/layers/vessels/ingestion.test.mjs", - "src/cityPresets.test.mjs" + "src/cityPresets.test.mjs", + "server/providers/cctv/range.js", + "src/data/cctvMediaRange.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 202159eab..8801d5c47 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -152,6 +152,7 @@ "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", + "server/providers/cctv/range.js", "server/providers/cctv/sources.js", "src/data/directionText.js", "server/providers/common/geo.js", diff --git a/server/providers/cctv.js b/server/providers/cctv.js index e1c2d8f85..5f601532f 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -10,11 +10,13 @@ import { fetchCctvImageFromUpstream, fetchTxdotSnapshot, fetchCctvMediaUpstream, + watchDownstreamClose, } from './cctv/media.js'; import { CCTV_FRAME_FETCH_TIMEOUT_MS, CCTV_MAX_SOURCES_CEILING, } from './cctv/constants.js'; +import { sanitizeCctvRangeHeader } from './cctv/range.js'; import { googleServerApiKey } from './places/google-key.js'; export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; /** @@ -218,15 +220,31 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { return; } + // Bound before the request goes out: most of the wait is before any + // header arrives, and a viewer who leaves during it must take the + // upstream request with them. + const downstream = watchDownstreamClose(res); try { const upstreamHeaders = { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', }; - const requestRange = req.headers?.range; + // Never forward the client's own string: a Range this proxy does + // not accept is dropped and the request proceeds without one. + const requestRange = sanitizeCctvRangeHeader(req.headers?.range); if (requestRange) upstreamHeaders.Range = requestRange; const upstream = await fetchCctvMediaUpstream(mediaUrl, { headers: upstreamHeaders, + signal: downstream.signal, }); + if (downstream.closed) { + // The headers arrived for a viewer who is no longer there. + try { + await upstream.body?.cancel(); + } catch { + /* already closed */ + } + return; + } const contentType = upstream.headers.get('content-type') || ''; if (!upstream.ok) { try { @@ -283,6 +301,11 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { }); return; } catch (error) { + if (downstream.closed) { + // The viewer left mid-request. That is not a camera fault and + // there is nobody to answer. + return; + } const timedOut = error?.name === 'AbortError' || error?.name === 'TimeoutError'; setHealth(cameraId, { diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 87e982f3b..d652f7150 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -156,9 +156,65 @@ export async function proxyMediaResponse( stream.on('error', () => { if (!res.writableEnded) res.end(); }); + + // A live camera feed has no end of its own. When the viewer goes away the + // upstream connection must go with it, or every abandoned view leaves a + // stream open against the camera host for as long as that host will hold it. + let released = false; + const releaseUpstream = () => { + if (released) return; + released = true; + stream.unpipe(res); + // Destroying the Node stream cancels the web body it wraps; the direct + // cancel covers a body that was never wrapped, and rejects harmlessly when + // the reader is already held. + stream.destroy(); + try { + const cancelled = upstream.body?.cancel?.(); + if (typeof cancelled?.catch === 'function') cancelled.catch(() => {}); + } catch { + /* already closed */ + } + }; + res.once('close', () => { + if (!res.writableEnded) releaseUpstream(); + }); + res.once('error', releaseUpstream); + stream.once('end', () => { + released = true; + }); stream.pipe(res); } +/** + * Watch a client response for an early goodbye. + * + * Bound BEFORE the upstream request goes out, because most of the waiting + * happens before any header comes back: a viewer who closes the tab while a + * slow camera is still thinking would otherwise leave that request running with + * nobody to receive it. + * + * @param {import('http').ServerResponse} res - The client response. + * @returns {{signal: AbortSignal, closed: boolean}} `signal` cancels the + * upstream request; `closed` says the client left before the response ended. + */ +export function watchDownstreamClose(res) { + const controller = new AbortController(); + const state = { + signal: controller.signal, + closed: false, + }; + const onClose = () => { + // A response that ended normally also emits close; only an early one counts. + if (res.writableEnded) return; + state.closed = true; + controller.abort(); + }; + res.once?.('close', onClose); + res.once?.('error', onClose); + return state; +} + /** Read a snapshot incrementally, retaining at most maxBytes of owned chunks. */ async function readCappedResponseBytes(upstream, maxBytes) { const declared = Number(upstream.headers.get('content-length')); @@ -229,14 +285,21 @@ export async function fetchCctvMediaUpstream( headers = {}, fetchImpl = fetch, timeoutMs = CCTV_MEDIA_FETCH_TIMEOUT_MS, + signal: downstream = null, } = {}, ) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + // The client going away cancels the upstream request, not just the response + // to it. + const onDownstreamAbort = () => controller.abort(); + if (downstream?.aborted) controller.abort(); + else downstream?.addEventListener?.('abort', onDownstreamAbort); try { return await fetchImpl(url, { headers, signal: controller.signal }); } finally { clearTimeout(timeoutId); + downstream?.removeEventListener?.('abort', onDownstreamAbort); } } diff --git a/server/providers/cctv/range.js b/server/providers/cctv/range.js new file mode 100644 index 000000000..d21e7dba5 --- /dev/null +++ b/server/providers/cctv/range.js @@ -0,0 +1,81 @@ +import { CCTV_MEDIA_MAX_BODY_BYTES } from './constants.js'; + +/** Digits allowed per Range position — bounds parse cost and absurd offsets. */ +const RANGE_MAX_DIGITS = 16; + +/** + * Validate, canonicalize and bound a client `Range` header before the media + * route forwards it to a third-party camera host. + * + * The route used to copy `req.headers.range` through verbatim. Two things + * follow from that. A multi-range value makes the upstream answer + * `multipart/byteranges`: when that body declares its length the relay's + * ceiling still applies to it, but the parts inside are the upstream's to + * choose and nothing here reads them, and a multipart body sent without a + * declared length streams through uncapped like any other length-less body. + * And a value the outbound request refuses to carry — anything with CR or LF + * in it — made `fetch` throw, and the route recorded that error text, which + * contains the client's own string, as the camera's health message. + * + * Bounding the Range bounds what is ASKED FOR, not what arrives: an upstream + * that ignores the Range and answers with a length-less chunked body still + * streams without a ceiling. That is how live feeds are served and is not + * changed here. What is bounded is the request's lifetime: it is cancelled when + * the viewer goes away, before the headers arrive as well as during the body. + * + * Anything that is not a single well-formed `bytes=` range is DROPPED and the + * request proceeds with no Range, which is what RFC 7233 §3.1 prescribes for a + * Range a server cannot understand: the client gets the whole body rather than + * an error it did not ask for. Accepted forms are `bytes=-`, + * `bytes=-` and `bytes=-`. + * + * Every accepted form is bounded to {@link CCTV_MEDIA_MAX_BODY_BYTES}, the same + * ceiling the relay applies to a declared response body — including the + * open-ended and suffix forms, which otherwise ask an upstream for a whole file + * of unknown size. A player that wants more than one bound's worth asks for the + * next range, which is ordinary partial-content behavior. + * + * @param {*} value - Raw `req.headers.range`. + * @param {number} [maxBytes] - Ceiling for the requested span. + * @returns {string} Canonical `bytes=...` value, or '' to send no Range. + */ +export function sanitizeCctvRangeHeader( + value, + maxBytes = CCTV_MEDIA_MAX_BODY_BYTES, +) { + if (typeof value !== 'string') return ''; + const raw = value.trim(); + if (!raw) return ''; + + // The unit is case-insensitive (RFC 7233 §2.1) and `bytes` is the only one + // this proxy understands. A comma anywhere fails this pattern, which is how + // multi-range is refused. + const match = /^bytes=(\d*)-(\d*)$/i.exec(raw); + if (!match) return ''; + + const [, firstText, lastText] = match; + if (firstText.length > RANGE_MAX_DIGITS || lastText.length > RANGE_MAX_DIGITS) + return ''; + // "bytes=-" carries neither position and is meaningless. + if (!firstText && !lastText) return ''; + + // Suffix form: the final N bytes. N === 0 is unsatisfiable by definition. + if (!firstText) { + const suffix = Number(lastText); + if (!Number.isSafeInteger(suffix) || suffix <= 0) return ''; + return `bytes=-${Math.min(suffix, maxBytes)}`; + } + + const first = Number(firstText); + if (!Number.isSafeInteger(first) || first < 0) return ''; + + const ceiling = first + maxBytes - 1; + if (!Number.isSafeInteger(ceiling)) return ''; + + // Open-ended: everything from `first` on, bounded to one span. + if (!lastText) return `bytes=${first}-${ceiling}`; + + const last = Number(lastText); + if (!Number.isSafeInteger(last) || last < first) return ''; + return `bytes=${first}-${Math.min(last, ceiling)}`; +} diff --git a/src/data/cctvMediaRange.test.mjs b/src/data/cctvMediaRange.test.mjs new file mode 100644 index 000000000..3e71d3e17 --- /dev/null +++ b/src/data/cctvMediaRange.test.mjs @@ -0,0 +1,588 @@ +// Client `Range` handling for the CCTV media proxy. The route used to relay +// req.headers.range verbatim to a third-party upstream; these pin what is now +// allowed through, both as string policy and through the mounted route with an +// injected upstream. +// +// Run with: npm test (node --test) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import { Readable } from 'node:stream'; +import { cctvProxy } from '../../server/providers/cctv.js'; +import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js'; +import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js'; + +test('single well-formed byte ranges pass through canonicalized', () => { + assert.equal(sanitizeCctvRangeHeader('bytes=0-1023'), 'bytes=0-1023'); + // A single range that names the same byte twice is legal. + assert.equal(sanitizeCctvRangeHeader('bytes=7-7'), 'bytes=7-7'); + // The unit is case-insensitive (RFC 7233 §2.1); surrounding space is legal. + assert.equal(sanitizeCctvRangeHeader(' BYTES=0-99 '), 'bytes=0-99'); +}); + +test('multi-range is dropped so the upstream never answers multipart/byteranges', () => { + // The relay forwards Content-Length/Content-Range and caps on a single + // declared body; a multipart response defeats that accounting. + assert.equal(sanitizeCctvRangeHeader('bytes=0-1,2-3'), ''); + assert.equal(sanitizeCctvRangeHeader('bytes=0-1, 100-200, 300-'), ''); +}); + +test('malformed, non-byte and CR/LF-bearing values are dropped, not forwarded', () => { + for (const bad of [ + 'bytes=abc-def', + 'bytes=', + 'bytes=-', + 'bytes=1-2-3', + '0-100', // no unit + 'items=0-100', // a unit the proxy does not understand + 'bytes=1.5-2', + 'bytes=-0', // zero-length suffix is unsatisfiable + 'bytes=0x10-0x20', + 'bytes=+5-10', + 'bytes=-5-10', + // An outbound request refuses to carry these, and the failure used to be + // written into the camera's health message. + 'bytes=0-10\r\nX-Injected: 1', + 'bytes=0-10\nHost: evil.example', + ]) { + assert.equal( + sanitizeCctvRangeHeader(bad), + '', + `expected drop: ${JSON.stringify(bad)}`, + ); + } +}); + +test('non-string and empty inputs yield no Range header', () => { + // req.headers.range is undefined on a normal GET, and Node hands back an + // array when a client sends the header twice. + assert.equal(sanitizeCctvRangeHeader(undefined), ''); + assert.equal(sanitizeCctvRangeHeader(null), ''); + assert.equal(sanitizeCctvRangeHeader(''), ''); + assert.equal(sanitizeCctvRangeHeader(' '), ''); + assert.equal(sanitizeCctvRangeHeader(['bytes=0-1', 'bytes=2-3']), ''); + assert.equal(sanitizeCctvRangeHeader(42), ''); +}); + +test('every accepted form is bounded to the relay body cap', () => { + // Explicit span. + assert.equal( + sanitizeCctvRangeHeader('bytes=0-999999999999'), + `bytes=0-${CAP - 1}`, + ); + assert.equal( + sanitizeCctvRangeHeader('bytes=1000-999999999999'), + `bytes=1000-${1000 + CAP - 1}`, + ); + // Open-ended: bounded too, rather than asking an upstream for a whole file + // of unknown size. + assert.equal( + sanitizeCctvRangeHeader('bytes=500-'), + `bytes=500-${500 + CAP - 1}`, + ); + // Suffix: bounded to one span of the tail. + assert.equal(sanitizeCctvRangeHeader('bytes=-500'), 'bytes=-500'); + assert.equal(sanitizeCctvRangeHeader('bytes=-999999999999'), `bytes=-${CAP}`); + // Exactly at the cap, and comfortably under it, are untouched. + assert.equal( + sanitizeCctvRangeHeader(`bytes=0-${CAP - 1}`), + `bytes=0-${CAP - 1}`, + ); + assert.equal(sanitizeCctvRangeHeader('bytes=0-1048575'), 'bytes=0-1048575'); +}); + +test('inverted and unsafe-integer positions are dropped', () => { + assert.equal(sanitizeCctvRangeHeader('bytes=500-100'), ''); + // Beyond Number.MAX_SAFE_INTEGER the arithmetic silently loses precision. + assert.equal(sanitizeCctvRangeHeader('bytes=99999999999999999-'), ''); + // Absurd digit counts are refused before any Number() conversion. + assert.equal(sanitizeCctvRangeHeader(`bytes=0-${'9'.repeat(64)}`), ''); +}); + +// --------------------------------------------------------------------------- +// The mounted route, with the upstream injected through globalThis.fetch. +// --------------------------------------------------------------------------- + +const CAMERA = { + id: 'range-fixture', + name: 'Range fixture', + lat: 30.27, + lon: -97.74, + feedType: 'video', + url: 'https://upstream.invalid/live.mp4', +}; + +/** A minimal upstream Response with a streaming body. */ +function upstreamResponse({ status = 206, 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: + body === null + ? null + : Readable.toWeb(Readable.from([Buffer.from(String(body))])), + arrayBuffer: async () => 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, serve one camera, and answer its upstream 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, + }; + process.env.CCTV_SOURCES_JSON = JSON.stringify([CAMERA]); + process.env.CCTV_SOURCES_FILE = 'absent-source-file.json'; + process.env.CCTV_FORCE_AUSTIN = '0'; + const nativeFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (url, init = {}) => { + requests.push({ + url: String(url), + headers: init.headers || {}, + signal: init.signal || null, + }); + 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], + ]) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + let handler = null; + const plugin = cctvProxy(); + plugin.configureServer({ + middlewares: { + use: (_route, fn) => { + handler = fn; + }, + }, + }); + assert.ok(handler, 'the CCTV provider must mount a middleware'); + + /** Drive one request through the mounted handler. */ + const call = async (path, headers = {}) => { + const { res, done } = recordingResponse(); + await handler({ url: path, headers, method: 'GET', on() {} }, res); + await done; + return res; + }; + /** Read the camera's current health entry. */ + const health = async () => { + const res = await call('/health'); + return JSON.parse(res.body).cameras.find(({ id }) => id === CAMERA.id); + }; + return { call, health, requests, handler: (req, res) => handler(req, res) }; +} + +test('a canonical range reaches the upstream and its 206 is passed through', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 206, + headers: { + 'content-type': 'video/mp4', + 'content-range': 'bytes 0-1023/4096', + 'content-length': '1024', + 'accept-ranges': 'bytes', + }, + body: 'partial', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' }); + assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023'); + assert.equal(res.statusCode, 206); + assert.equal(res.headers['Content-Range'], 'bytes 0-1023/4096'); + assert.equal(res.headers['Accept-Ranges'], 'bytes'); + assert.equal(res.body, 'partial'); +}); + +test('an unbounded seek is bounded before it is forwarded', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 206, + headers: { 'content-type': 'video/mp4' }, + body: 'tail', + }), + ); + await app.call(`/media/${CAMERA.id}`, { range: 'bytes=500-' }); + assert.equal(app.requests.at(-1).headers.Range, `bytes=500-${500 + CAP - 1}`); +}); + +test('a rejected range is dropped and the request proceeds without one', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 200, + headers: { 'content-type': 'video/mp4' }, + body: 'whole body', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { + range: 'bytes=0-10\r\nX-Injected: 1', + }); + // Documented choice: drop the header rather than answer 400, so a client + // that sends something this proxy will not carry still gets its media. + assert.equal(app.requests.at(-1).headers.Range, undefined); + assert.equal(res.statusCode, 200); + assert.equal(res.body, 'whole body'); +}); + +test('a rejected range leaves the camera health report untouched', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 200, + headers: { 'content-type': 'video/mp4' }, + body: 'whole body', + }), + ); + await app.call(`/media/${CAMERA.id}`, { + range: 'bytes=0-10\r\nX-Injected: 1', + }); + const entry = await app.health(); + assert.equal(entry.status, 'ok'); + // The client's own string must never appear in a report other callers read. + assert.doesNotMatch(JSON.stringify(entry), /X-Injected/); +}); + +test('a multi-range request is served whole rather than as multipart', async (t) => { + const app = mount(t, ({ init }) => + upstreamResponse({ + status: init?.headers?.Range ? 206 : 200, + headers: { + 'content-type': init?.headers?.Range + ? 'multipart/byteranges; boundary=x' + : 'video/mp4', + }, + body: 'whole body', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { + range: 'bytes=0-1,2-3', + }); + assert.equal(app.requests.at(-1).headers.Range, undefined); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['Content-Type'], 'video/mp4'); +}); + +test('an upstream 416 is reported as the upstream status, not as a proxy error', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 416, + headers: { 'content-range': 'bytes */1024' }, + body: null, + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { + range: `bytes=${2 ** 40}-${2 ** 40 + 10}`, + }); + assert.equal(res.statusCode, 416); + assert.match(res.body, /416/); + const entry = await app.health(); + assert.equal(entry.status, 'degraded'); + assert.equal(entry.message, 'Upstream HTTP 416'); +}); + +test('an upstream that ignores the range still serves its whole body', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 200, + headers: { 'content-type': 'video/mp4', 'content-length': '10' }, + body: 'whole body', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' }); + assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023'); + assert.equal(res.statusCode, 200); + assert.equal(res.body, 'whole body'); +}); + +test('an oversized declared body is refused even when a bounded range asked for it', async (t) => { + const app = mount(t, () => + upstreamResponse({ + status: 206, + headers: { + 'content-type': 'video/mp4', + 'content-length': String(CAP + 1), + }, + body: 'too big', + }), + ); + const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' }); + assert.equal(res.statusCode, 502); + assert.match(res.body, /exceeds size cap/); +}); + +test('a client that disconnects mid-stream stops the upstream read', async (t) => { + // A live feed with no end of its own, counting what it is asked to produce. + let produced = 0; + let stopped = false; + const endlessBody = () => + Readable.toWeb( + Readable.from( + (async function* () { + try { + for (;;) { + produced += 1; + yield Buffer.from('frame-chunk-'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } finally { + stopped = true; + } + })(), + ), + ); + const app = mount(t, () => ({ + ok: true, + status: 206, + headers: { + get: (name) => + String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null, + }, + body: endlessBody(), + arrayBuffer: async () => Buffer.alloc(0), + })); + + const server = http.createServer((req, res) => { + req.url = req.url.replace('/api/cctv', '') || '/'; + Promise.resolve(app.handler(req, res)).catch(() => {}); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + const failures = []; + const onUncaught = (error) => failures.push(error); + process.on('uncaughtException', onUncaught); + t.after(() => process.off('uncaughtException', onUncaught)); + + await new Promise((resolve, reject) => { + const request = http.get( + { + host: '127.0.0.1', + port, + path: `/media/${CAMERA.id}`.replace(/^/, '/api/cctv'), + headers: { Range: 'bytes=0-1023' }, + }, + (response) => { + response.once('data', () => { + // Walk away mid-body, the way a browser does when the operator + // switches camera. + request.destroy(); + resolve(); + }); + response.on('error', () => resolve()); + }, + ); + request.on('error', reject); + }); + + await new Promise((resolve) => setTimeout(resolve, 150)); + const afterDisconnect = produced; + await new Promise((resolve) => setTimeout(resolve, 400)); + // The point: the camera host is no longer being read from. Without the + // release this counter keeps climbing for as long as the upstream will feed. + assert.equal( + produced, + afterDisconnect, + `the upstream was still being read after the client left (${afterDisconnect} → ${produced} chunks)`, + ); + assert.equal(stopped, true, 'the upstream body was never released'); + assert.deepEqual( + failures.map((error) => error?.message), + [], + 'a client walking away must not fault the server', + ); + const entry = await app.health(); + // The camera was serving fine; the client left. Nothing about that is a + // camera fault other viewers should see. + assert.equal(entry.status, 'ok'); +}); + +test('a client that leaves before the headers arrive cancels the upstream request', async (t) => { + // The camera is slow, or dead: nothing comes back until the request is + // cancelled. Everything worth testing here happens before any header. + let aborted = false; + const app = mount(t, ({ init }) => { + return new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => { + aborted = true; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }); + }); + + const server = http.createServer((req, res) => { + req.url = req.url.replace('/api/cctv', '') || '/'; + Promise.resolve(app.handler(req, res)).catch(() => {}); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + const failures = []; + const onUncaught = (error) => failures.push(error); + process.on('uncaughtException', onUncaught); + t.after(() => process.off('uncaughtException', onUncaught)); + + const request = http.get({ + host: '127.0.0.1', + port, + path: `/api/cctv/media/${CAMERA.id}`, + headers: { Range: 'bytes=0-1023' }, + }); + request.on('error', () => {}); + // Let the route reach its upstream request, then walk away before it answers. + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(app.requests.length, 1, 'the upstream request went out'); + assert.equal(aborted, false, 'nothing was cancelled while the client waited'); + request.destroy(); + await new Promise((resolve) => setTimeout(resolve, 250)); + + assert.equal(aborted, true, 'the upstream request outlived the client'); + assert.deepEqual( + failures.map((error) => error?.message), + [], + 'a client leaving early must not fault the server', + ); + // The camera did nothing wrong; the viewer left. + const entry = await app.health(); + assert.notEqual(entry?.status, 'degraded'); +}); + +test('a folded CR/LF range never reaches the upstream through a real client', async (t) => { + // Node's own parser, not a hand-built headers object, so the reachability of + // this shape through an ordinary HTTP request is shown rather than assumed. + const app = mount(t, () => + upstreamResponse({ + status: 200, + headers: { 'content-type': 'video/mp4' }, + body: 'whole body', + }), + ); + const server = http.createServer((req, res) => { + req.url = req.url.replace('/api/cctv', '') || '/'; + Promise.resolve(app.handler(req, res)).catch(() => {}); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + // A client can fold a value across a continuation line; Node's parser + // rejoins it, so what the route sees contains whitespace a header cannot + // carry back out. + const raw = await new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1', () => { + socket.write( + `GET /api/cctv/media/${CAMERA.id} HTTP/1.1\r\n` + + 'Host: 127.0.0.1\r\n' + + 'Range: bytes=0-10\r\n\tX-Injected: 1\r\n' + + 'Connection: close\r\n\r\n', + ); + }); + let text = ''; + socket.on('data', (chunk) => { + text += chunk.toString('utf8'); + }); + socket.on('end', () => resolve(text)); + socket.on('error', reject); + }); + + // Either Node's parser refuses the request outright, or it reaches the route + // and the route drops the value — but it must never be forwarded. + const reachedRoute = app.requests.length > 0; + assert.match(raw, /^HTTP\/1\.1 (200|400)/); + if (reachedRoute) { + assert.equal( + app.requests.at(-1).headers.Range, + undefined, + 'nothing was forwarded', + ); + assert.match(raw, /^HTTP\/1\.1 200/); + } else { + assert.match(raw, /^HTTP\/1\.1 400/); + } + console.log( + ` [range] folded CR/LF request ${reachedRoute ? 'reached the route and was dropped' : 'was refused by the HTTP parser'}`, + ); + const entry = await app.health(); + assert.doesNotMatch(JSON.stringify(entry ?? {}), /X-Injected/); +}); + +test('two sequential seeks are served as two partial responses', async (t) => { + const app = mount(t, ({ init }) => { + const range = init?.headers?.Range || ''; + const [, first, last] = /bytes=(\d+)-(\d+)/.exec(range) || []; + return upstreamResponse({ + status: 206, + headers: { + 'content-type': 'video/mp4', + 'content-range': `bytes ${first}-${last}/4096`, + 'content-length': String(Number(last) - Number(first) + 1), + 'accept-ranges': 'bytes', + }, + body: `chunk-${first}`, + }); + }); + const first = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-99' }); + const second = await app.call(`/media/${CAMERA.id}`, { + range: 'bytes=100-199', + }); + assert.equal(app.requests[0].headers.Range, 'bytes=0-99'); + assert.equal(app.requests[1].headers.Range, 'bytes=100-199'); + assert.equal(first.statusCode, 206); + assert.equal(second.statusCode, 206); + assert.equal(first.headers['Content-Range'], 'bytes 0-99/4096'); + assert.equal(second.headers['Content-Range'], 'bytes 100-199/4096'); + assert.equal(first.body, 'chunk-0'); + assert.equal(second.body, 'chunk-100'); +});