Dashboard renders untrusted feed and LLM text into innerHTML with no HTML escaping (stored XSS)
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:
function cleanText(t){return t.replace(/'/g,"'").replace(/!/g,"!").replace(/&/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:
popup.querySelector('.pp-head').textContent=head||'';
popup.querySelector('.pp-text').innerHTML=text||''; // <-- sinkCallers 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:
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
- Add a single
esc()output-escaping helper (& < > " ') and apply it at every interpolation of data-derived text in the render templates. - Make
cleanText()escape its result after stripping, so all of its existing call sites become safe without changing them. - Escape the
popText/showPopupbody at construction for every producer; keepinnerHTMLonly where the template itself supplies<br>, with all interpolated parts escaped. - In
cliInject(), neutralize</scriptand<!--in the serialized blob using JS string escapes, matching the guardserver.mjsalready applies to the locale blob. - Add regression tests that extract the helpers from
jarvis.htmland run known XSS payloads through them, plus a source lint asserting no known sink regresses to a raw interpolation.
Source: calesthio/Crucix