← back to Costa Rica
costa-rica: fix stored XSS on public/place.html — escape every scraped-field sink (cycle 24) — TK-10346
d79ae5e8f11bd0dbf4b418ee72653cf58dbdff54 · 2026-09-24 04:04:38 -0700 · Steve
Cold Cody audit of the ingest layer: external scraped fields (name/website/
email/source/image_url/credit — all attacker-editable via Google Business
Profile / OSM tags / portal listings) were concatenated RAW into innerHTML and
Leaflet bindPopup on the live consumer page, and place.html had no esc() at all.
A business named `X"><img src=x onerror=…>` executed on every visitor to /p/<slug>
(cookie/session theft, no auth). No SQL injection anywhere (ingest parameterizes).
Fix — escape at output, allow-list URL protocols:
- Port esc() (same helper index.html uses) + add safeUrl() (http/mailto/tel only,
blocks javascript:/data:) + safeImg() (http(s) + root-relative /img/…, blocks
script + protocol-relative — safeUrl would wrongly blank a localized image).
- Wrap all sinks: credit, d-email, d-website, d-source, bindPopup(p.name), the
website CTA href, and the hero imgEl.src.
- Rebuild the siblings block via DOM (createElement + textContent + a DOM-set,
encodeURI'd background-image) — image_url landed in a CSS url() inside a style
attribute, a context HTML-escaping can't secure.
- Align the CTA email href with d-email (encodeURIComponent — blocks mailto param
injection).
- test/place-page-xss.test.js: evals the SHIPPED esc/safeUrl/safeImg against attack
payloads AND asserts every named-field sink routes through them (regression guard).
Two Cody passes (audit + diff-gate); the gate caught the initially-missed hero
imgEl.src. Suite 205 -> 209, serial green. NOTE: this closes the hole in source;
the prod deploy to Kamatera is gated (see pending-approval memo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
Files touched
M public/place.htmlA test/place-page-xss.test.js
Diff
commit d79ae5e8f11bd0dbf4b418ee72653cf58dbdff54
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 24 04:04:38 2026 -0700
costa-rica: fix stored XSS on public/place.html — escape every scraped-field sink (cycle 24) — TK-10346
Cold Cody audit of the ingest layer: external scraped fields (name/website/
email/source/image_url/credit — all attacker-editable via Google Business
Profile / OSM tags / portal listings) were concatenated RAW into innerHTML and
Leaflet bindPopup on the live consumer page, and place.html had no esc() at all.
A business named `X"><img src=x onerror=…>` executed on every visitor to /p/<slug>
(cookie/session theft, no auth). No SQL injection anywhere (ingest parameterizes).
Fix — escape at output, allow-list URL protocols:
- Port esc() (same helper index.html uses) + add safeUrl() (http/mailto/tel only,
blocks javascript:/data:) + safeImg() (http(s) + root-relative /img/…, blocks
script + protocol-relative — safeUrl would wrongly blank a localized image).
- Wrap all sinks: credit, d-email, d-website, d-source, bindPopup(p.name), the
website CTA href, and the hero imgEl.src.
- Rebuild the siblings block via DOM (createElement + textContent + a DOM-set,
encodeURI'd background-image) — image_url landed in a CSS url() inside a style
attribute, a context HTML-escaping can't secure.
- Align the CTA email href with d-email (encodeURIComponent — blocks mailto param
injection).
- test/place-page-xss.test.js: evals the SHIPPED esc/safeUrl/safeImg against attack
payloads AND asserts every named-field sink routes through them (regression guard).
Two Cody passes (audit + diff-gate); the gate caught the initially-missed hero
imgEl.src. Suite 205 -> 209, serial green. NOTE: this closes the hole in source;
the prod deploy to Kamatera is gated (see pending-approval memo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
public/place.html | 39 ++++++++++++++-----
test/place-page-xss.test.js | 94 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 124 insertions(+), 9 deletions(-)
diff --git a/public/place.html b/public/place.html
index 03a743e..c19485b 100644
--- a/public/place.html
+++ b/public/place.html
@@ -154,6 +154,19 @@
function setHTML(id, html) { document.getElementById(id).innerHTML = html; }
function setText(id, v) { document.getElementById(id).textContent = v || '—'; }
+ // XSS guards — place fields (name/website/email/source/image_url) come from
+ // external scrapers (Google Places / OSM / MEIC / ICT / portals), all
+ // attacker-editable, so every value interpolated into innerHTML/bindPopup MUST
+ // be escaped, and every URL that lands in an href/src/CSS-url() MUST have its
+ // protocol allow-listed (block javascript:/data:). esc() is the same helper
+ // index.html already uses. (Cody cold audit, cycle 24 — stored XSS.)
+ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
+ function safeUrl(u){var s=String(u==null?'':u).trim();return /^(https?:|mailto:|tel:)/i.test(s)?s:'#';}
+ // Image src guard: images can be absolute http(s) (Wikimedia) OR site-relative
+ // /img/... (after cr-localize-images), so safeUrl (http/mailto/tel only) would
+ // wrongly blank a localized image. Allow http(s) + single-slash root-relative;
+ // block javascript:/data:/vbscript: and protocol-relative //. Returns '' if unsafe.
+ function safeImg(u){var s=String(u==null?'':u).trim();return (/^https?:\/\//i.test(s)||/^\/[^/]/.test(s))?s:'';}
fetch('/api/places/' + encodeURIComponent(slug)).then(r => r.ok ? r.json() : Promise.reject(r)).then(p => {
document.title = p.name + ' — Costa Rica Directory';
@@ -168,7 +181,7 @@
// Hero image (place's own → falls back to region's Wikipedia hero)
const hero = document.getElementById('hero');
const credit = document.getElementById('credit');
- const heroImg = p.effective_image_url || p.image_url || p.region_image_url;
+ const heroImg = safeImg(p.effective_image_url || p.image_url || p.region_image_url);
if (heroImg) {
const imgEl = document.createElement('img');
imgEl.src = heroImg; imgEl.alt = p.name; imgEl.loading = 'eager';
@@ -177,7 +190,7 @@
const creditText = p.effective_image_credit || p.region_image_credit;
const creditUrl = p.effective_image_source_url || p.region_image_source_url;
if (creditText && creditUrl) {
- credit.innerHTML = '📷 <a href="'+creditUrl+'" target="_blank" rel="noopener nofollow">'+creditText+'</a>';
+ credit.innerHTML = '📷 <a href="'+esc(safeUrl(creditUrl))+'" target="_blank" rel="noopener nofollow">'+esc(creditText)+'</a>';
credit.style.display = '';
} else if (creditText) {
credit.textContent = '📷 ' + creditText;
@@ -195,9 +208,9 @@
// CTAs
const ctas = document.getElementById('ctas');
const ctaList = [];
- if (p.website) ctaList.push({label:'Visit website ↗', href: p.website, alt:false});
+ if (p.website) ctaList.push({label:'Visit website ↗', href: safeUrl(p.website), alt:false});
if (p.phone) ctaList.push({label:'Call ' + p.phone, href:'tel:' + p.phone.replace(/[^+\d]/g,''), alt:false});
- if (p.email) ctaList.push({label:'Email', href:'mailto:' + p.email, alt:true});
+ if (p.email) ctaList.push({label:'Email', href:'mailto:' + encodeURIComponent(p.email), alt:true});
const lat = p.lat || p.region_lat, lng = p.lng || p.region_lng;
if (lat && lng) ctaList.push({label:'Open in Google Maps ↗', href:`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`, alt:true});
if (p.region_slug) ctaList.push({label:'More in ' + p.region_name, href:'/r/' + p.region_slug, alt:true});
@@ -210,15 +223,15 @@
setText('d-address', p.address);
setText('d-phone', p.phone);
setText('d-coords', (lat && lng) ? (+lat).toFixed(4) + ', ' + (+lng).toFixed(4) : null);
- setHTML('d-email', p.email ? '<a href="mailto:'+p.email+'">'+p.email+'</a>' : '—');
- setHTML('d-website', p.website ? '<a href="'+p.website+'" target="_blank" rel="noopener noreferrer">'+p.website.replace(/^https?:\/\//,'').replace(/\/$/,'')+'</a>' : '—');
+ setHTML('d-email', p.email ? '<a href="mailto:'+encodeURIComponent(p.email)+'">'+esc(p.email)+'</a>' : '—');
+ setHTML('d-website', p.website ? '<a href="'+esc(safeUrl(p.website))+'" target="_blank" rel="noopener noreferrer">'+esc(p.website.replace(/^https?:\/\//,'').replace(/\/$/,''))+'</a>' : '—');
setText('d-cedula', p.cedula_juridica);
setText('d-price', p.price_range);
setText('d-rating', p.rating ? (+p.rating).toFixed(1) : null);
setText('d-status', p.status);
setText('d-added', p.created_at ? new Date(p.created_at).toLocaleDateString() : null);
const dSrc = document.getElementById('d-source');
- if (p.source_url){ dSrc.innerHTML = '<a href="'+p.source_url+'" target="_blank" rel="noopener nofollow">'+(p.source||'link')+' ↗</a>'; }
+ if (p.source_url){ dSrc.innerHTML = '<a href="'+esc(safeUrl(p.source_url))+'" target="_blank" rel="noopener nofollow">'+esc(p.source||'link')+' ↗</a>'; }
else { dSrc.textContent = p.source || '—'; }
// Map (Leaflet + OpenStreetMap)
@@ -226,7 +239,7 @@
const map = L.map('map', { zoomControl: true, scrollWheelZoom: false }).setView([+lat, +lng], p.lat ? 15 : 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' }).addTo(map);
const marker = L.marker([+lat, +lng]).addTo(map);
- marker.bindPopup('<strong>'+p.name+'</strong><br>'+(p.address||p.region_name||'')).openPopup();
+ marker.bindPopup('<strong>'+esc(p.name)+'</strong><br>'+esc(p.address||p.region_name||'')).openPopup();
} else {
document.getElementById('map').textContent = 'No coordinates on file.';
document.getElementById('map').style.display = 'flex';
@@ -246,7 +259,15 @@
p.siblings_in_region.forEach(s => {
const a = document.createElement('a');
a.className = 'sib'; a.href = '/p/' + encodeURIComponent(s.slug);
- a.innerHTML = `<div class="pic"${s.image_url ? ' style="background-image:url('+s.image_url+')"' : ''}></div><div class="meta"><span>${s.name}</span><span class="v">${(s.vertical||'').replace(/_/g,' ')}</span></div>`;
+ // Built via DOM (not innerHTML): image_url would otherwise land inside a
+ // CSS url(...) in a style attribute, a context HTML-escaping can't secure.
+ const pic = document.createElement('div'); pic.className = 'pic';
+ if (s.image_url) { const u = safeUrl(s.image_url); if (u !== '#') pic.style.backgroundImage = 'url("' + encodeURI(u) + '")'; }
+ const meta = document.createElement('div'); meta.className = 'meta';
+ const nm = document.createElement('span'); nm.textContent = s.name || '';
+ const vt = document.createElement('span'); vt.className = 'v'; vt.textContent = (s.vertical||'').replace(/_/g,' ');
+ meta.appendChild(nm); meta.appendChild(vt);
+ a.appendChild(pic); a.appendChild(meta);
sibs.appendChild(a);
});
}
diff --git a/test/place-page-xss.test.js b/test/place-page-xss.test.js
new file mode 100644
index 0000000..0270c12
--- /dev/null
+++ b/test/place-page-xss.test.js
@@ -0,0 +1,94 @@
+'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');
+});
← a52a312 cycle 23 docs: YOLO_NOTES ledger — cr_iban dead-payout fix +
·
back to Costa Rica
·
cycle 24 docs: YOLO_NOTES ledger — stored-XSS fix on place.h 6564e86 →