[object Object]

← 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

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)>'), '&lt;img src=x onerror=alert(1)&gt;');
+  assert.equal(esc('a"><b'), 'a&quot;&gt;&lt;b');
+  assert.equal(esc("' onmouseover='alert(1)"), '&#39; onmouseover=&#39;alert(1)');
+  assert.equal(esc('Tom & Jerry'), 'Tom &amp; 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(/&quot;/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 →