← 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
M agents/shared/agent-base.js
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=>({'&':'&','<':'<','>':'>'}[c]));
+const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →