[object Object]

← back to Norma

dashboard: harden escaping for attribute context (contrarian gate)

2439b98d191818efb6b215d2a19c3b48b463cc13 · 2026-07-10 12:06:20 -0700 · Steve Abrams

Contrarian flagged attribute-context XSS: esc() escaped &<> but not quotes,
while permalink->href and thumb->src render in double-quoted attributes; in
live mode discover() pulls hashtag-search results (other users' captions/
permalinks) and helmet CSP is disabled, so an injection would be unmitigated.
Fixes: esc() now also escapes " and ', and href/src accept only http(s) URLs
(blocks javascript: scheme). Proven via headless Playwright with a hostile
payload: no dialog, window.__pwned=false, 0 rogue img[onerror]/script nodes,
malicious permalink -> href='#', caption rendered as literal escaped text.

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

Files touched

Diff

commit 2439b98d191818efb6b215d2a19c3b48b463cc13
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 10 12:06:20 2026 -0700

    dashboard: harden escaping for attribute context (contrarian gate)
    
    Contrarian flagged attribute-context XSS: esc() escaped &<> but not quotes,
    while permalink->href and thumb->src render in double-quoted attributes; in
    live mode discover() pulls hashtag-search results (other users' captions/
    permalinks) and helmet CSP is disabled, so an injection would be unmitigated.
    Fixes: esc() now also escapes " and ', and href/src accept only http(s) URLs
    (blocks javascript: scheme). Proven via headless Playwright with a hostile
    payload: no dialog, window.__pwned=false, 0 rogue img[onerror]/script nodes,
    malicious permalink -> href='#', caption rendered as literal escaped text.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 agents/shared/agent-base.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/agents/shared/agent-base.js b/agents/shared/agent-base.js
index 2b1359a..0f45e5f 100644
--- a/agents/shared/agent-base.js
+++ b/agents/shared/agent-base.js
@@ -185,7 +185,7 @@ function createAgentServer({ name, port, skills = {}, cronJobs = [], dashboard =
 <div class="foot">Control is API/skill-driven — see <code>/api/status</code>, <code>/api/audit?limit=50</code>, <code>/api/cron/status</code>, <code>POST /api/cron/{start,stop}</code>, or the <code>instagram-account-manager</code> skill.</div>
 <script>
 const S=${DATA};
-const esc=s=>String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
+const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
 const fmt=t=>{if(!t)return '<span class="muted">—</span>';const d=new Date(t);return isNaN(d)?esc(t):d.toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});};
 document.getElementById('dot').style.background='var(--ok)';
 document.getElementById('uptime').textContent='up '+Math.floor((S.uptime||0)/60)+'m';
@@ -205,7 +205,7 @@ if(D.error){stats.innerHTML='<span style="color:var(--bad)">dashboard error: '+e
 else{const ms=(D.metrics||[]);stats.innerHTML=ms.length?ms.map(m=>'<div class="stat"><div class="v">'+esc(m.value)+'</div><div class="l">'+esc(m.label)+'</div>'+(m.sub?'<div class="s">'+esc(m.sub)+'</div>':'')+'</div>').join(''):'<span class="muted">no metrics available</span>';}
 document.getElementById('dashNote').textContent=D.note||'';
 const media=document.getElementById('media');const mm=(D.media||[]);
-media.innerHTML=mm.length?mm.map(x=>{const cap=esc(x.caption||'');const link=x.permalink?esc(x.permalink):'#';const thumb=x.thumb?esc(x.thumb):'';const img=thumb?'<img class="ph" data-fallback="1" src="'+thumb+'" loading="lazy">':'<div class="ph"></div>';return '<a class="tile" href="'+link+'" target="_blank" rel="noopener noreferrer">'+img+'<div class="cap">'+(cap||'<span class="muted">no caption</span>')+'</div><div class="meta">'+esc(x.type||'')+(x.when?' · '+fmt(x.when):'')+'</div></a>';}).join(''):'<span class="muted">no media</span>';
+media.innerHTML=mm.length?mm.map(x=>{const cap=esc(x.caption||'');const link=(x.permalink&&/^https?:/i.test(x.permalink))?esc(x.permalink):'#';const thumb=(x.thumb&&/^https?:/i.test(x.thumb))?esc(x.thumb):'';const img=thumb?'<img class="ph" data-fallback="1" src="'+thumb+'" loading="lazy">':'<div class="ph"></div>';return '<a class="tile" href="'+link+'" target="_blank" rel="noopener noreferrer">'+img+'<div class="cap">'+(cap||'<span class="muted">no caption</span>')+'</div><div class="meta">'+esc(x.type||'')+(x.when?' · '+fmt(x.when):'')+'</div></a>';}).join(''):'<span class="muted">no media</span>';
 document.querySelectorAll('img.ph[data-fallback]').forEach(im=>{im.onerror=function(){const d=document.createElement('div');d.className='ph';im.replaceWith(d);};});
 setTimeout(()=>location.reload(),15000);
 </script></body></html>`);

← a1a4ebc Norma dashboard: enrich GET / with insights + media grid (ca  ·  back to Norma  ·  auto-save: 2026-07-11T08:42:28 (1 files) — app/api/auth/logi c421505 →