// 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, Writable } from 'node:stream'; import { cctvProxy } from '../../server/providers/cctv.js'; import { proxyMediaResponse } from '../../server/providers/cctv/media.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'); }); // --------------------------------------------------------------------------- // The body idle deadline. proxyMediaResponse is driven directly here because // the deadline is only injectable through its options; the route always passes // the production value. // --------------------------------------------------------------------------- /** * A response to pipe into, built on a real Writable so the backpressure the * proxy reads is the stream's own rather than a flag the test sets. With * `drains: false` the bytes are accepted and never flushed, which is what a * viewer on a slow link looks like from the server side. */ function pipeTarget({ drains = true } = {}) { const res = new Writable({ // One byte is enough to leave a target that never empties its buffer // permanently in need of a drain. highWaterMark: drains ? undefined : 1, write(_chunk, _encoding, callback) { if (drains) callback(); }, }); res.writeHead = (status, headers) => { res.statusCode = status; res.headers = headers || {}; }; return res; } /** * An upstream that answers with headers, sends one chunk, and then either * keeps producing every `everyMs` or goes silent. `released` reports the body's * own cleanup: a cancelled fetch body lands in the web stream's cancel hook. */ function mediaUpstream({ everyMs = 0 } = {}) { const state = { produced: 0, released: false }; let ticker = null; const body = new ReadableStream({ start(controller) { const send = () => { state.produced += 1; controller.enqueue(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])); }; send(); if (everyMs > 0) { ticker = setInterval(send, everyMs); ticker.unref(); } }, cancel() { state.released = true; if (ticker) clearInterval(ticker); }, }); state.upstream = { ok: true, status: 200, headers: { get: (name) => String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null, }, body, arrayBuffer: async () => Buffer.alloc(0), }; return state; } test('an upstream that goes silent after its headers is released at the idle deadline', async (t) => { const feed = mediaUpstream(); const res = pipeTarget(); t.after(() => res.destroy()); await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 40 }); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(feed.released, false, 'released before the deadline was due'); await new Promise((resolve) => setTimeout(resolve, 120)); // Without the deadline this stream is held open against the camera host for // as long as that host will keep the socket, with no bytes ever arriving. assert.equal(feed.released, true, 'the silent upstream was never released'); assert.equal(res.writableEnded, true, 'the client was left waiting'); }); test('a live feed that keeps producing is not cut off by the idle deadline', async (t) => { const feed = mediaUpstream({ everyMs: 10 }); const res = pipeTarget(); t.after(() => res.destroy()); await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 40 }); await new Promise((resolve) => setTimeout(resolve, 250)); assert.equal(feed.released, false, 'a healthy feed was torn down'); assert.equal(res.writableEnded, false, 'a healthy feed was ended early'); assert.ok( feed.produced > 5, `the feed stopped producing (${feed.produced} chunks)`, ); }); test('a client that cannot keep up is not mistaken for a stalled upstream', async (t) => { // Nothing arrives from upstream while the pipe is paused, which looks exactly // like silence unless the response is asked whether it is still draining. const feed = mediaUpstream({ everyMs: 10 }); const res = pipeTarget({ drains: false }); t.after(() => res.destroy()); await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 30 }); await new Promise((resolve) => setTimeout(resolve, 200)); assert.equal( res.writableNeedDrain, true, 'the client was not the bottleneck', ); assert.equal(feed.released, false, 'a slow viewer tore down a healthy feed'); assert.equal(res.writableEnded, false, 'a slow viewer ended the response'); });