Fix CCTV provider cancellation and credential-safe errors

This commit is contained in:
Said committed 2026-10-19 23:05:37 +01:00
1 parent 1bccc295f0
commit 5b4f5ed4a0
8 files changed
+293 -63

No files matched your search

+40
View File
@@ -127,3 +127,43 @@ for (const status of [429, 500, 503]) {
assert.doesNotMatch(lines[0], /secret-key-value/);
});
}
for (const failure of ['transport', 'invalid-json']) {
test(`Ontario ${failure} failures cannot log reflected keys or request URLs`, async (t) => {
const lines = captureConsole(t);
const key = 'synthetic secret&/=%value';
t.mock.method(globalThis, 'fetch', async (url) => {
if (failure === 'transport')
throw new Error(`request failed: ${url} ${key}`);
return new Response(`not JSON: ${url} ${key}`);
});
assert.deepEqual(
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: key }),
[],
);
assert.equal(lines.length, 1);
assert.match(lines[0], /Ontario 511 camera download error/);
assert.equal(lines[0].includes(key), false);
assert.equal(lines[0].includes('synthetic'), false);
assert.equal(lines[0].includes('https://'), false);
});
}
test('Ontario encodes reserved key characters and refuses credential-bearing redirects', async (t) => {
const lines = captureConsole(t);
const key = 'synthetic &/=% value';
let request;
t.mock.method(globalThis, 'fetch', async (url, init) => {
request = { url: new URL(url), init };
return Response.json([ROW]);
});
const cameras = await loadOntarioSourcesFromOpenData(
{},
{ ONTARIO_511_API_KEY: key },
);
assert.equal(request.url.searchParams.get('key'), key);
assert.equal(request.init.redirect, 'error');
assert.equal(cameras.length, 1);
assert.equal(JSON.stringify(cameras).includes('synthetic'), false);
assert.equal(lines.join(' ').includes('synthetic'), false);
});
+47 -60
View File
@@ -563,6 +563,7 @@ export async function loadOntarioSourcesFromOpenData(
url.searchParams.set('key', key);
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
@@ -646,10 +647,11 @@ export async function loadOntarioSourcesFromOpenData(
`[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
} catch {
// Transport and JSON errors may reflect the credential-bearing request.
// Do not log upstream exception text, response bodies or URLs.
console.warn(
'[CCTV] Ontario 511 camera download error:',
error?.message || error,
'[CCTV] Ontario 511 camera download error (network or invalid response)',
);
return [];
}
@@ -1246,66 +1248,50 @@ export function parseTarkteeDatexImages(xml) {
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadTarkteeSourcesFromDatex() {
// Own both transports before either can reject. A response that arrives
// after cleanup must also be discarded instead of escaping Promise.all.
const abort = new AbortController();
const signal = AbortSignal.any([
abort.signal,
AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
]);
const responses = [];
const discardBoth = async () => {
abort.abort();
await Promise.allSettled(
responses.map((response) => response.body?.cancel()),
);
return [];
};
const fetchXml = async (url) => {
const response = await fetch(url, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal,
});
if (signal.aborted) {
await Promise.allSettled([response.body?.cancel()]);
signal.throwIfAborted();
}
responses.push(response);
if (!response.ok) {
throw new Error(
response.status >= 300 && response.status < 400
? 'catalog redirected; redirects are not followed'
: `catalog HTTP ${response.status}`,
);
}
return response;
};
try {
const [locResp, imgResp] = await Promise.all([
fetch(TARKTEE_LOCATIONS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
}),
fetch(TARKTEE_IMAGES_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
}),
fetchXml(TARKTEE_LOCATIONS_URL),
fetchXml(TARKTEE_IMAGES_URL),
]);
const [locXml, imgXml] = await Promise.all([
readResponseTextCapped(locResp, TARKTEE_MAX_CATALOG_BYTES, signal),
readResponseTextCapped(imgResp, TARKTEE_MAX_CATALOG_BYTES, signal),
]);
// Both responses are fetched in parallel; if either is rejected or overflows
// its byte cap, both bodies must be released so neither transport leaks.
const discardBoth = async () => {
await Promise.allSettled([
locResp?.body?.cancel(),
imgResp?.body?.cancel(),
]);
return [];
};
if (
(locResp.status >= 300 && locResp.status < 400) ||
(imgResp.status >= 300 && imgResp.status < 400)
) {
console.warn(
'[CCTV] Tarktee DATEX catalog redirected; redirects are not followed',
);
return discardBoth();
}
if (!locResp.ok) {
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
return discardBoth();
}
if (!imgResp.ok) {
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
return discardBoth();
}
let locXml;
let imgXml;
const readAbort = new AbortController();
try {
[locXml, imgXml] = await Promise.all([
readResponseTextCapped(
locResp,
TARKTEE_MAX_CATALOG_BYTES,
readAbort.signal,
),
readResponseTextCapped(
imgResp,
TARKTEE_MAX_CATALOG_BYTES,
readAbort.signal,
),
]);
} catch (error) {
readAbort.abort();
await discardBoth();
throw error;
}
const locations = parseTarkteeDatexLocations(locXml);
const images = parseTarkteeDatexImages(imgXml);
if (!locations.size || !images.size) {
@@ -1366,6 +1352,7 @@ export async function loadTarkteeSourcesFromDatex() {
);
return prioritized;
} catch (error) {
await discardBoth();
console.warn(
'[CCTV] Tarktee DATEX download error:',
error?.message || error,