fix(cctv): harden the Catalonia catalog fetch and frame-link allowlist

Address review feedback on #595:

- Reject frame links carrying userinfo (user:pass@) in
  normalizeCataloniaImageUrl, so catalog data cannot inject credentials
  into the server-side frame request on an allowlisted host.
- Fetch cameres.xml over https (verified: same 200 body, no redirect),
  refuse redirects with the unread body released, and read the body
  through readResponseTextCapped with a 1 MiB CATALONIA_MAX_CATALOG_BYTES
  cap instead of an unbounded resp.text().
- Add regression tests for userinfo links on every allowed host, the
  refused redirect, and declared and streamed oversize bodies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ulihhhhandClaude Opus 5.5 committed 2026-09-27 12:01:17 +02:00
1 parent 21a86c05bd
commit 320a1d6bfd
5 files changed
+163 -14

No files matched your search

+6 -2
View File
@@ -238,7 +238,8 @@ export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/**
* Catalonia traffic cameras (Servei Català de Trànsit): one keyless WFS/GML
* XML list (verified 2026-09-15, hourly catalog refresh). The feed carries a
* XML list (verified 2026-09-15, hourly catalog refresh), fetched over https
* (verified 2026-09-27: same 200 body, no redirect). The feed carries a
* `font` field naming the actual publisher per camera — SCT's own highway
* cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras are
* kept, each confirmed to return a real frame. Frame URLs are checked
@@ -246,7 +247,10 @@ export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
* publisher serves from its own host.
*/
export const CATALONIA_CAMERAS_URL =
'http://www.gencat.cat/transit/opendata/cameres.xml';
'https://www.gencat.cat/transit/opendata/cameres.xml';
/** Byte ceiling for the Catalonia catalog read (~90 KB observed 2026-09-27),
* so a runaway body cannot be buffered without limit. */
export const CATALONIA_MAX_CATALOG_BYTES = 1024 * 1024;
/** Allowed image hosts, one per kept `font` publisher in the feed. */
export const CATALONIA_IMAGE_HOSTS = Object.freeze(
new Set([
+34 -4
View File
@@ -56,6 +56,7 @@ import {
CALGARY_DOWNTOWN,
CALGARY_MAX_CATALOG_BYTES,
CATALONIA_CAMERAS_URL,
CATALONIA_MAX_CATALOG_BYTES,
CATALONIA_IMAGE_HOSTS,
CATALONIA_FONT_CREDIT,
CATALONIA_FONT_ELEVATION_M,
@@ -89,7 +90,10 @@ import {
prioritizeSources,
} from './normalize.js';
import { directionToHeading } from '../../../src/data/directionText.js';
import { readResponseJsonCapped } from '../common/http.js';
import {
readResponseJsonCapped,
readResponseTextCapped,
} from '../common/http.js';
/**
* Fetch and parse Austin traffic camera records from the city Open Data portal.
*
@@ -1649,7 +1653,10 @@ export function parseCataloniaXml(xml) {
/**
* Validate and normalize one Catalonia feed `link` against the publisher
* host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects
* anything else, including a host that merely resembles an allowed one.
* anything else, including a host that merely resembles an allowed one, and
* any URL carrying userinfo (`https://user:[email protected]/...`): the
* hostname check alone would pass it, letting catalog data inject
* credentials into the server-side frame request.
*
* SCT's own `RenderService` frame link is a special case, for two stacked
* reasons (both verified 2026-09-15):
@@ -1679,6 +1686,7 @@ export function normalizeCataloniaImageUrl(rawUrl) {
return '';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
if (parsed.username || parsed.password) return '';
if (!CATALONIA_IMAGE_HOSTS.has(parsed.hostname)) return '';
if (parsed.hostname === 'mct.gencat.cat') {
@@ -1725,19 +1733,41 @@ export function cataloniaCameraName(carretera, municipi) {
* shared `provider`. The feed carries no heading, so every camera takes the
* id-hash fallback, low-confidence pose (same as TfL and Fintraffic).
*
* The list decides ids, coordinates, labels and which allowlisted frame path
* is proxied, so it is fetched over https, redirects are refused (the list
* host cannot be steered or downgraded) and the body read is capped at
* CATALONIA_MAX_CATALOG_BYTES.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadCataloniaSourcesFromOpenData() {
try {
const resp = await fetch(CATALONIA_CAMERAS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
// A response this loader will not read still owns its transport until the
// body is released, so every rejection path cancels before returning.
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Catalonia catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Catalonia camera download failed:', resp.status);
return [];
return discard();
}
const xml = await resp.text();
const xml = await readResponseTextCapped(resp, CATALONIA_MAX_CATALOG_BYTES);
const rows = parseCataloniaXml(xml);
if (!rows.length) return [];