ADR-0168: XSS is defended at output and in the browser, in layers
- Status: accepted
- Date: 2026-10-06
- Deciders: Mahdi Amirabdollahi
- Supersedes: ADR-0122
Context
ADR-0122 gave every response a Content-Security-Policy that keeps scripts, connections and workers on Studio's origin. That stops an injected script from loading or talking to another origin. It does not stop one that is already inline in a string Studio writes into the page, and it does nothing about the other response headers.
Studio stores and shows broker data verbatim: message bodies and headers, queue and address names, configuration. An attacker who can publish a message or name a queue controls those bytes. The interface renders them through React, which escapes text, and through a code highlighter, which is the one place Studio sets raw HTML (Mantine's CodeHighlight uses dangerouslySetInnerHTML).
Rewriting input is not an option. A filter that strips or escapes request bodies would corrupt the data an operator came to look at (a message body that is HTML is a legitimate message), and it does not defend reliably, because the dangerous form depends on where a value is later used.
Decision
Studio does not touch input. It defends where data leaves it, and in the browser, in layers.
The Content-Security-Policy of ADR-0122 gains three directives:
script-src-attr 'none': an inline event-handler attribute never runs, even in markup that got in.require-trusted-types-for 'script': the DOM's script sinks (innerHTML,outerHTML,insertAdjacentHTML,document.write, a script'ssrc,new Worker(url),eval) take only Trusted Types values, never a plain string.trusted-types default dompurify studio#worker: the policies that may exist, and no others.
The whole policy is:
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types default dompurify studio#workerThree Trusted Types policies, each made in
web/src/ui/trustedTypes.ts:defaulttakes a plain string forcreateHTMLonly when it holds no<, so it cannot open a tag. It exists because Mantine writes its CSS into<style>elements throughinnerHTML(MantineCssVariables,InlineStyles, and the colour-scheme switch), a sink that has no other route. It does not definecreateScriptorcreateScriptURL, so every script and script URL is refused. It is not a pass-through: any markup throws.dompurifyis DOMPurify's own policy. The highlighter's markup goes throughDOMPurify.sanitizewith only<span class style>allowed before it reaches the page, and arrives asTrustedHTML. The highlighter escapes its input already; this is the second line, and it is the only HTML Studio renders.studio#workermakes the script URL of the layout worker (ELK), and throws for any URL that is not on Studio's origin.
Other response headers, on every response (the shell, static assets, API answers and refusals) through Spring Security's headers configuration:
Referrer-Policy: no-referrer, aPermissions-Policythat turns off camera, microphone, geolocation, payment, USB and the other sensors,Cross-Origin-Opener-Policy: same-origin,Cross-Origin-Resource-Policy: same-origin, and the existingX-Content-Type-Options: nosniffandX-Frame-Options: DENY. HSTS stays Spring's default: sent over HTTPS only, never on plain HTTP. A plugin asset's ownContent-Security-Policy: sandboxstill wins for its SVGs.Untrusted bytes are never a page. Every response that is not JSON is a download with a non-HTML type: the support bundle (
application/zip, attachment), the exportedbroker.xml(application/xml, now an attachment too), and plugin assets (content type by extension,nosniff, sandboxed SVG). The SQL result, message body and recovery-code files are made in the browser from aBloband saved with thedownloadattribute. Errors, including those that escape MVC, areapplication/problem+jsonand never echo the request (ApiErrorController, a test).The frontend lint (
web/eslint.config.js) bansdangerouslySetInnerHTML, assignment toinnerHTMLandouterHTML,insertAdjacentHTML,document.write,eval,new Function, a string passed tosetTimeoutorsetInterval, andjavascript:addresses. A test fixes that each rule fires. Test files may carry an attack payload (no-script-urloff), and nothing else is exempt.Links built from data go through
safeHref(web/src/ui/safeHref.ts):http:,https:,mailto:and addresses on Studio's own origin. It guards a plugin's vendor link and icon, an identity provider's start paths, and the bug-report link.The browser tests run under the policy. The
browserproject is served with the Trusted Types directives, so a sink that takes a string fails a test as it would fail for a viewer. A test holds them equal to the server's.
Consequences
- An injection bug that gets markup into a string Studio writes into the page throws instead of running, and an inline handler in markup that does get in never fires. Both the server and the browser are told, so neither is the only defence.
style-src 'unsafe-inline'stays. Mantine sets inlinestyleattributes and<style>elements at run time, and style injection runs no script. A nonce would have to reach every render. Revisit when Mantine can take its styles without them.- A plugin UI that sets raw HTML stops working. A bundled rich-text editor, a Markdown renderer or any code path that writes a markup string to
innerHTMLthrows under the policy, and a plugin cannot make its own policy: only the three names above exist. Its author renders through React or shows the text. Plugins built with React, Mantine and the SDK need no change. This is called out in the plugin guide and the template. defaultis a standing exception: any code on the page may still write a string without<to a markup sink. That string cannot create an element or run script, which is why it is allowed, but it is a rule to keep: never widen it to take a<.- DOMPurify is a new dependency, used in one function.
- Violations are not reported. A reporting endpoint (
report-uri,POST /api/v1/csp-reports) would only log, and nothing reads that log. The browser tests, the UI sweep and the plugin template's end-to-end run all fail on a violation, which finds it before a viewer does. - A future Studio screen that needs a new sink needs a new named policy in the header and in
trustedTypes.ts, with a reason, in a new ADR.
Alternatives considered
- A request filter that strips or escapes input. Rejected for the reasons above: it corrupts stored data and does not defend reliably.
- A
defaultpolicy that returns its input. It would makerequire-trusted-types-fordecorative: every string would pass. The no-<rule keeps Mantine working and refuses the rest. - Replacing Mantine's style injection (
withCssVariables={false}, constructable style sheets).InlineStylesand the colour-scheme switch use the sink inside Mantine's components, so there is nothing to configure, and forking it costs more than a one-rule default policy. - Trusted Types in report-only first. The browser tests already run enforced, and a report-only header would send nothing anywhere.
- Sanitising on the server before sending. The highlighter's markup is made in the browser, and server-side sanitising of data the UI renders as text would change the data.
- Allow-listing a
style-srchash or nonce instead of'unsafe-inline'. See the consequence above.