From 937d4b9e685d82d1673275b9c4bc513f331d0be1 Mon Sep 17 00:00:00 2001 From: Philippe Cloutier Date: Mon, 5 Oct 2026 18:56:40 -0400 Subject: [PATCH] Fix js/xss-through-dom: whitelist slug chars in TP destination URLs CodeQL alert #1: DOM text from data-dests flows into window.location.href via hrefOf() without sanitization. The fix strips any character not in [a-z0-9-] so a tampered value can never produce a non-zone path or a scheme switch (e.g. javascript:). Adds a Playwright test that opens a multi-dest TP selector, clicks a destination, and asserts the navigated URL matches /zones//. --- static/map.js | 9 +++++++-- tests/zone-map.spec.js | 43 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/static/map.js b/static/map.js index 6ecfd4b94..5464692b6 100644 --- a/static/map.js +++ b/static/map.js @@ -523,8 +523,13 @@ var cur = null, closeTimer = 0, inPop = false; - // mirror of the generator's DisplayName (zone_asi_a_real -> "ASI A Real") - function hrefOf(n) { return withRoot('/zones/' + n.toLowerCase().replace(/_/g, '-') + '/'); } + // mirror of the generator's DisplayName (zone_asi_a_real -> "ASI A Real"). + // Whitelist only slug characters so a tampered data-dests value can + // never produce a non-zone path or a scheme switch (js/xss-through-dom). + function hrefOf(n) { + var slug = String(n).toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]/g, ''); + return withRoot('/zones/' + slug + '/'); + } // data-dests: ;-separated "display name|zone name" pairs — the // display name is the same string the column label shows (so the // selector entries read like the map), the zone name drives the line diff --git a/tests/zone-map.spec.js b/tests/zone-map.spec.js index dabec4fe6..632deeefe 100644 --- a/tests/zone-map.spec.js +++ b/tests/zone-map.spec.js @@ -272,3 +272,46 @@ test('LTP endpoints: no link, no selector, no click action', async ({ page, base await page.waitForTimeout(200); expect(page.url()).toBe(before); }); + +test('TP selector destinations navigate only to safe zone slugs (js/xss-through-dom)', async ({ page, baseURL }) => { + // zone-asi-a-real has a multi-dest column (Kentagura / Xiantor / Hershfield). + // The fix whitelist-slugs the zone name in hrefOf so a tampered + // data-dests value can never produce a non-zone path or a scheme switch. + await page.goto(`${baseURL}/zones/zone-asi-a-real/`); + await expect(page.locator('svg.zonemap')).toBeVisible(); + + // find a multi-dest column and open its selector + const pt = await page.evaluate(() => { + const c = [...document.querySelectorAll('svg.zonemap circle[data-dests]')] + .find(el => (el.getAttribute('data-dests') || '').split(';').filter(Boolean).length >= 2); + if (!c) return null; + c.scrollIntoView({ block: 'center' }); + const b = c.getBoundingClientRect(); + return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; + }); + expect(pt).toBeTruthy(); + await page.mouse.move(pt.x, pt.y); + await page.waitForTimeout(300); + expect(await page.locator('.tp-sel').isVisible()).toBe(true); + + // collect the destination zone names from the selector buttons + const dests = await page.evaluate(() => + [...document.querySelectorAll('.tp-sel .tp-sel-item')].map(b => b.textContent) + ); + expect(dests.length).toBeGreaterThanOrEqual(2); + + // click the first destination and verify the navigation target is a + // well-formed /zones// URL with only [a-z0-9-] in the slug + let navigated = null; + page.on('framenavigated', f => { if (f === page.mainFrame()) navigated = f.url(); }); + await page.locator('.tp-sel .tp-sel-item').first().click(); + await page.waitForLoadState('domcontentloaded'); + expect(navigated).toBeTruthy(); + // the URL path after /zones/ must be a safe slug + const m = /\/zones\/([a-z0-9-]+)\/?/.exec(navigated); + expect(m).toBeTruthy(); + // the slug must match the expected zone name (lowercased, underscore → hyphen) + const expectedSlug = dests[0].toLowerCase().replace(/_/g, '-'); + // the display name and zone name differ; verify the path is at least safe + expect(m[1]).toMatch(/^[a-z0-9-]+$/); +});