From 0ad75095a777f471200226955585b6f5aa08cce3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 21 Oct 2026 15:07:20 -0700 Subject: [PATCH] Validate provider response formats CCTV frames and the Street View fallback accept only JPEG, PNG, GIF, WebP and AVIF, parsed from the media type essence and sent under the canonical type; any other type is cancelled and falls through to the next fallback as a failed upstream does. Live media must match its feed kind (video/mp4, video/webm, or multipart/x-mixed-replace with a boundary) before the camera is reported healthy or any byte is relayed, and its redirects now stay on the registered origin like frames. Every /api/cctv route uses the same-site gate. The GBFS proxy relays only bodies that parse as JSON, re-serialized under a fixed application/json type. --- scripts/package-boundaries.json | 2 + server/providers/cctv.js | 66 ++++++--- server/providers/cctv/media.js | 94 ++++++++++++- server/providers/gbfs.js | 17 ++- src/data/cctvMediaFeedType.test.mjs | 210 +++++++++++++++++++++++++++- src/data/cctvProxy.test.mjs | 54 +++++++ src/data/gbfsProxy.test.mjs | 58 ++++++++ 7 files changed, 468 insertions(+), 33 deletions(-) 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; + } +});