← 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','&':'&'}[c]));
+ } catch { return '#'; }
+ };
+ const escAttr = (s) => String(s || '').replace(/[<>"'&]/g, c => ({'<':'<','>':'>','"':'"',"'":''','&':'&'}[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 => ({'<':'<','>':'>','"':'"',"'":'''}[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 →