[object Object]

← back to Ventura Corridor

fix(security): escape source_url + scheme-validate href in /news rail (XSS)

7594bb3b98533408a8dfd2f22cb00e6c35a3c400 · 2026-05-07 13:52:09 -0700 · SteveStudio2

Found by codex-2way round-1 PROSECUTOR (Kimi K2.5 cloud) auditing tick 41's
per-business news rail on the corridor map. n.source_url was interpolated
raw into href, allowing javascript: protocol injection or attribute breakout
via crafted URLs. Defense in depth - even though the scraper currently
controls news_items inserts, never trust HTML scraped from third parties.

Adds:
  - safeUrl()   parses URL, rejects non-http(s) schemes, percent-encodes
                special chars before interpolation
  - escAttr()   shared HTML-attribute escaper covering ampersand, lt, gt, quote, apos

Both applied to href, title, and date inside the news rail block in
public/index.html showPanel(). DEFENDER (qwen3:14b@Mac2) had refuted the
finding twice; PROSECUTOR was right both times.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7594bb3b98533408a8dfd2f22cb00e6c35a3c400
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 13:52:09 2026 -0700

    fix(security): escape source_url + scheme-validate href in /news rail (XSS)
    
    Found by codex-2way round-1 PROSECUTOR (Kimi K2.5 cloud) auditing tick 41's
    per-business news rail on the corridor map. n.source_url was interpolated
    raw into href, allowing javascript: protocol injection or attribute breakout
    via crafted URLs. Defense in depth - even though the scraper currently
    controls news_items inserts, never trust HTML scraped from third parties.
    
    Adds:
      - safeUrl()   parses URL, rejects non-http(s) schemes, percent-encodes
                    special chars before interpolation
      - escAttr()   shared HTML-attribute escaper covering ampersand, lt, gt, quote, apos
    
    Both applied to href, title, and date inside the news rail block in
    public/index.html showPanel(). DEFENDER (qwen3:14b@Mac2) had refuted the
    finding twice; PROSECUTOR was right both times.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/index.html | 17 +++++++++++++++--
 1 file changed, 15 insertions(+), 2 deletions(-)

diff --git a/public/index.html b/public/index.html
index 2580f3b..01c9313 100644
--- a/public/index.html
+++ b/public/index.html
@@ -314,13 +314,26 @@ async function showPanel(id) {
     const ownerLine = r.enrichment ? `<div class="pn-row"><span class="k">Ownership</span><span class="v metal">${r.enrichment.ownership_class}${r.enrichment.parent_brand ? ' · ' + r.enrichment.parent_brand : ''}</span></div>` : '';
     // News rail — surfaces what was scraped from this business's own site.
     const newsItems = r.news || [];
+    // Defensive: source_url comes from scraped HTML on third-party sites.
+    // Validate scheme (http/https only — no javascript:/data:) and HTML-escape
+    // all attribute values before interpolating into href. Caught by codex-2way
+    // round-1 PROSECUTOR audit on tick 41's news rail commit.
+    const safeUrl = (raw) => {
+      try {
+        const u = new URL(String(raw || ''));
+        if (u.protocol !== 'http:' && u.protocol !== 'https:') return '#';
+        return u.href.replace(/[<>"' &]/g, c => ({'<':'%3C','>':'%3E','"':'%22',"'":'%27',' ':'%20','&':'&amp;'}[c]));
+      } catch { return '#'; }
+    };
+    const escAttr = (s) => String(s || '').replace(/[<>"'&]/g, c => ({'<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;','&':'&amp;'}[c]));
     const newsBlock = newsItems.length ? (() => {
       const items = newsItems.slice(0, 4).map(n => {
         const dt = n.published_guess || n.fetched_at;
         const dateStr = dt ? new Date(dt).toLocaleDateString('en-US', { month:'short', day:'numeric' }) : '';
         const title = (n.title || '(untitled)').slice(0, 90);
-        const safeTitle = title.replace(/[<>"']/g, c => ({'<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
-        return `<a href="${n.source_url}" target="_blank" rel="noopener noreferrer" style="display:block;padding:6px 0;border-bottom:1px dotted var(--rule);text-decoration:none;color:var(--ink)"><div style="font-size:12px;line-height:1.35">${safeTitle}</div><div style="font-size:9px;color:var(--ink-mute);font-family:'JetBrains Mono',monospace;letter-spacing:.16em;margin-top:2px;text-transform:uppercase">${dateStr}</div></a>`;
+        const safeTitle = escAttr(title);
+        const href = safeUrl(n.source_url);
+        return `<a href="${href}" target="_blank" rel="noopener noreferrer" style="display:block;padding:6px 0;border-bottom:1px dotted var(--rule);text-decoration:none;color:var(--ink)"><div style="font-size:12px;line-height:1.35">${safeTitle}</div><div style="font-size:9px;color:var(--ink-mute);font-family:'JetBrains Mono',monospace;letter-spacing:.16em;margin-top:2px;text-transform:uppercase">${escAttr(dateStr)}</div></a>`;
       }).join('');
       return `
       <div style="margin:14px 0 0;padding:10px 12px;background:rgba(184,153,104,0.08);border:1px solid var(--rule)">

← 0418331 feat(news): news_count badge on /buildings.html tenant rows  ·  back to Ventura Corridor  ·  fix(generate_features): tighten banned-phrase regex to catch d444435 →