← back to Costa Rica
test/place-page-xss.test.js
95 lines
'use strict';
// Stored-XSS regression guard for public/place.html (Cody cold audit, cycle 24).
//
// place.html renders place fields (name/website/email/source/image_url) that come
// from external scrapers (Google Places / OSM / MEIC / ICT / portals) — all
// attacker-editable. It USED to concat them raw into innerHTML/bindPopup, so a
// business named `X"><img src=x onerror=…>` executed on the live consumer page.
// This test (a) evals the ACTUAL esc()/safeUrl() helpers shipped in the page and
// proves they neutralize payloads, and (b) asserts the specific sinks now route
// through them — it goes red if a raw sink is reintroduced.
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'place.html'), 'utf8');
// Pull the real one-line helpers out of the shipped page and eval them, so this
// tests the code that actually runs — not a re-implementation.
function extract(name, sig) {
const m = HTML.match(new RegExp('function ' + name + '\\(' + sig + '\\)\\{.*\\}', ));
assert.ok(m, `place.html must define ${name}() (the XSS guard helper)`);
// eslint-disable-next-line no-new-func
return new Function(sig, m[0] + '\n return ' + name + '(' + sig + ');');
}
const esc = extract('esc', 's');
const safeUrl = extract('safeUrl', 'u');
const safeImg = extract('safeImg', 'u');
test('esc() neutralizes every HTML-significant character', () => {
assert.equal(esc('<img src=x onerror=alert(1)>'), '<img src=x onerror=alert(1)>');
assert.equal(esc('a"><b'), 'a"><b');
assert.equal(esc("' onmouseover='alert(1)"), '' onmouseover='alert(1)');
assert.equal(esc('Tom & Jerry'), 'Tom & Jerry');
assert.equal(esc(null), '');
assert.equal(esc(undefined), '');
// The literal exploit from the audit must not survive as live markup.
const payload = 'Villa X"><img src=x onerror=fetch(String.fromCharCode(47))>';
const out = esc(payload);
assert.ok(!/<img/.test(out), 'the <img> tag must be escaped, not preserved');
assert.ok(!/"[ >]/.test(out.replace(/"/g, '')), 'no raw quote survives to break an attribute');
});
test('safeUrl() blocks script/data URLs but passes real link protocols', () => {
assert.equal(safeUrl('javascript:alert(1)'), '#');
assert.equal(safeUrl(' JavaScript:alert(1)'), '#', 'case + leading space must not bypass');
assert.equal(safeUrl('data:text/html,<script>alert(1)</script>'), '#');
assert.equal(safeUrl('vbscript:msgbox'), '#');
assert.equal(safeUrl(null), '#');
assert.equal(safeUrl('https://example.com/x'), 'https://example.com/x');
assert.equal(safeUrl('http://example.com'), 'http://example.com');
assert.equal(safeUrl('mailto:a@b.com'), 'mailto:a@b.com');
assert.equal(safeUrl('tel:+50688880000'), 'tel:+50688880000');
});
test('safeImg() allows real image URLs (absolute http(s) + localized /img/…) and blocks script/protocol-relative', () => {
// legit shapes that MUST survive (or a localized hero image blanks)
assert.equal(safeImg('https://upload.wikimedia.org/x.jpg'), 'https://upload.wikimedia.org/x.jpg');
assert.equal(safeImg('http://example.com/a.png'), 'http://example.com/a.png');
assert.equal(safeImg('/img/regions/tamarindo.jpg'), '/img/regions/tamarindo.jpg', 'localized root-relative image must pass');
// dangerous / undesired shapes -> '' (falsy -> no <img> created)
assert.equal(safeImg('javascript:alert(1)'), '');
assert.equal(safeImg('data:text/html,<script>alert(1)</script>'), '');
assert.equal(safeImg('//evil.example/track.png'), '', 'protocol-relative is blocked');
assert.equal(safeImg(' JavaScript:alert(1)'), '');
assert.equal(safeImg(null), '');
});
test('every external-value sink in place.html routes through esc()/safeUrl()/safeImg() (no raw concat)', () => {
// Hero image src — the field the original audit named; must be safeImg-guarded.
assert.match(HTML, /const heroImg = safeImg\(/, 'hero image URL must be safeImg-guarded at the source');
assert.match(HTML, /imgEl\.src = heroImg;/, 'imgEl.src takes the already-guarded heroImg');
// CTA email href aligned with d-email (encodeURIComponent — blocks mailto param injection).
assert.match(HTML, /href:'mailto:' \+ encodeURIComponent\(p\.email\)/, 'CTA email href must encode p.email');
// Leaflet popup: must escape the scraped name + address, never concat them raw.
assert.match(HTML, /bindPopup\('<strong>'\+esc\(p\.name\)/, 'map popup must esc(p.name)');
assert.ok(!/bindPopup\('<strong>'\+p\.name/.test(HTML), 'raw p.name in bindPopup must be gone');
// Website: escaped + protocol-guarded in both the CTA href and the details link.
assert.match(HTML, /href:\s*safeUrl\(p\.website\)/, 'website CTA href must be safeUrl-guarded');
assert.match(HTML, /esc\(safeUrl\(p\.website\)\)/, 'd-website href must be esc(safeUrl(...))');
assert.ok(!/'<a href="'\+p\.website\+'"/.test(HTML), 'raw p.website in an href must be gone');
// Email, source, image credit.
assert.match(HTML, /'<a href="mailto:'\+encodeURIComponent\(p\.email\)\+'">'\+esc\(p\.email\)/, 'd-email must encode/esc');
assert.match(HTML, /esc\(safeUrl\(p\.source_url\)\)/, 'd-source href must be esc(safeUrl(...))');
assert.match(HTML, /esc\(safeUrl\(creditUrl\)\)/, 'image credit href must be esc(safeUrl(...))');
// Siblings: the image_url CSS-url() sink must no longer be built via innerHTML.
assert.ok(!/a\.innerHTML\s*=/.test(HTML), 'the siblings innerHTML sink (image_url in CSS url()) must be gone');
assert.match(HTML, /pic\.style\.backgroundImage\s*=\s*'url\("'\s*\+\s*encodeURI\(u\)/, 'sibling image must be a DOM-set, encodeURI-d background-image');
});