Files
gods-eye-view/src/data/cctvMediaRange.test.mjs
T
Bilawal SidhuandMaher Kurdi da700be77f fix(cctv): bound the Range header and release abandoned camera streams (#512)
Supersedes #253 by @Maher-Reven, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: Maher Kurdi <[email protected]>
2026-09-14 19:17:52 -05:00

589 lines
20 KiB
JavaScript

// 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');
});