Baike.dev
All toolsAI codingTrendingOpen sourceNewsSubmit
Log in
Back to tool/Back to issues
#134·Crucix

Dashboard renders untrusted feed and LLM text into innerHTML with no HTML escaping (stored XSS)

Author: Chirag6722Created Aug 13, 2026Updated Aug 13, 2026

Problem

dashboard/public/jarvis.html renders every panel by interpolating sweep data into innerHTML templates. There is no HTML-escaping function anywhere in the file. Feed-derived text — RSS/GDELT headlines, Telegram post text and channel names, ACLED locations, NOAA headlines, WHO summaries, KiwiSDR receiver names, CelesTrak satellite names, and LLM-generated idea text — is interpolated raw into ~10 innerHTML assignments.

cleanText() (the only filter present) is a tag stripper, not an escaper:

javascript
function cleanText(t){return t.replace(/&#39;/g,"'").replace(/&#33;/g,"!").replace(/&amp;/g,"&").replace(/<[^>]+>/g,'')}

It is also applied to only a handful of fields. Everything else reaches the DOM untouched.

Affected sinks

1. Map popups — no filtering at all. showPopup() assigns the body via innerHTML:

javascript
popup.querySelector('.pp-head').textContent=head||'';
popup.querySelector('.pp-text').innerHTML=text||'';   // <-- sink

Callers pass third-party strings directly:

Source Field Path
ACLED e.location, e.country popText: `${e.fatalities} fatalities<br>${e.location}, ${e.country}`
NOAA a.headline popText:a.headline||''
WHO w.summary popText:w.summary||''
KiwiSDR r.name popText:`${r.name}<br>Zone: ${z.region}`
OpenSky a.top[] country names air-hotspot popup
GDELT g.name popText:g.name||''
Maritime cp.note popText:cp.note
EPA s.analyte, s.state RadNet popup

2. The Ideas panel — LLM output rendered as markup. idea.title, idea.text, idea.rationale, idea.risk, and idea.ticker are all interpolated raw. This is the most concerning path because it is reachable end-to-end by an outside party: lib/llm/ideas.mjs:101 feeds up to 1500 characters of urgent Telegram post text into the idea prompt, and the model's response is rendered as HTML. A hostile post in any monitored OSINT channel that induces the model to echo markup becomes stored XSS on the dashboard.

3. Other raw interpolations — D.tSignals (<p>${s}</p>), delta rows (${s.label}, ${s.reason||s.label||s.key}, ${s.from}→${s.to}), Safecast ${s.site}, Telegram ${p.channel} and ${f} (urgent flags), market tile ${q.name||q.symbol}, CelesTrak country keys, and D.space.signals joined with <br>.

4. Server-side script-block breakout in dashboard/inject.mjs. cliInject() writes the serialized sweep into the HTML as a <script> variable:

javascript
html = html.replace(/^(let|const) D = .*;\s*$/m, () => 'let D = ' + json + ';');

The HTML parser still honours </script and <!-- inside a script element, so a single RSS title or Telegram post containing </script><img src=x onerror=...> terminates the block and the remainder is parsed as markup — regardless of any client-side escaping. Note server.mjs:249 already does exactly this guard for the injected locale blob (.replace(/<\/script>/gi, '<\/script>')); the far larger data blob does not.

Root cause

The dashboard was written as a local-only viewer where all inputs were implicitly trusted, and no escaping layer was ever introduced. That assumption does not hold: every field above originates from a third party, and the project also ships a public deployment (crucix.live) and a PUBLIC_URL setting for hosted instances, where the dashboard is served to browsers that never see the underlying feeds.

Why cleanText is not sufficient

Beyond covering only a few fields, it is a denylist. It leaves &, ", ' intact, so it cannot protect an attribute context, and stripping-then-inserting is fragile by construction. Escaping on output is the correct control.

Proposed solution

  1. Add a single esc() output-escaping helper (& < > " ') and apply it at every interpolation of data-derived text in the render templates.
  2. Make cleanText() escape its result after stripping, so all of its existing call sites become safe without changing them.
  3. Escape the popText/showPopup body at construction for every producer; keep innerHTML only where the template itself supplies <br>, with all interpolated parts escaped.
  4. In cliInject(), neutralize </script and <!-- in the serialized blob using JS string escapes, matching the guard server.mjs already applies to the locale blob.
  5. Add regression tests that extract the helpers from jarvis.html and run known XSS payloads through them, plus a source lint asserting no known sink regresses to a raw interpolation.

Source: calesthio/Crucix

View original on GitHubView discussion on GitHub