Skip to content

test(security): the web UI and what its origin serves - #406

Merged
dkackman merged 3 commits into
developfrom
test/security-web
Sep 24, 2026
Merged

dkackman merged 3 commits into
developfrom
test/security-web

Conversation

@dkackman

Copy link
Copy Markdown
Owner

Second pass after dkackman/diffusers-workflow#391, which covered the engine, API routes and MCP tools but not the browser. Tests only; no production or UI source changes. Two files:

  • ui/e2e/security.spec.ts (Playwright, real server + real Chromium): 10 tests, one of them a test.fail finding.
  • tests/test_security_web_headers.py (pytest): 21 passing, 11 strict xfails.

Why this matters: the UI stores the API token in localStorage, and the same origin serves the UI, /outputs and /inputs, neither of which needs a token. So any script that runs on that origin owns the token.

Method (e2e). In a throwaway workspace (e2e-xss, deleted in afterAll) the spec plants payloads in every field an untrusted author controls:

  • workflow description, variable default and step argument
  • prompt text, description and tag
  • file names in the gallery and asset libraries
  • a text output
  • a job that writes text/html

It then visits each page that shows them. Every payload sets document.documentElement.dataset.xss, and alerts are caught as dialogs. Each check first waits for the payload to be visible as text, so a page that failed to render can't pass. It also asserts that no img[src=x], [onerror]/[onload] element or javascript: link ended up in the DOM.

Coverage

Area Tests Result
UI escaping security.spec.ts: workflow catalog, workflow page, workflow editor (Monaco), gallery + opening a hostile-named tile, assets, prompt library, prompt editor, job page pass: Svelte escapes everything I could reach, and there's no {@html} or innerHTML in ui/src
Active content on the UI origin security.spec.ts › a run that writes text/html test.fail (finding 1)
test_security_web_headers.py::TestActiveOutputs::test_outputs_does_not_serve_it_as_a_live_document[.html,.xhtml,.xml,.svg], test_keep_output_cannot_carry_one_into_assets xfail
test_the_engine_writes_it[text/html,text/xml] (the precondition), .txt served as text/plain, uploads refuse .html/.svg/.xml/.xhtml, keep refuses a rename to .svg pass
Headers TestBrowserHeaders: CSP, frame protection, nosniff xfail
API answers JSON as application/json pass
CORS TestCrossOriginReads: no Access-Control-Allow-Origin or -Credentials for a foreign Origin on API, media or UI routes; a foreign preflight is refused; a same-origin request passes pass
Download names TestDownloadNames: quotes, ;, RTL override, CR/LF in a file name can't add a header or a second filename=; the listing reports hostile names as JSON data pass

Findings

  1. Stored XSS through a workflow's own output. High.
    • dw/content_types.py leaves text/* result types permissive by design. A workflow with "result": {"content_type": "text/html"} validates clean and writes an .html file with whatever the step produced (text/xml writes .xml).
    • /outputs (no token) serves it as text/html on the UI origin: no Content-Disposition: attachment, no CSP sandbox.
    • The job page links it with target="_blank".
    • Observed in Chromium: the output's script set document.title to stolen:"secret-probe", the token planted in localStorage.
    • Exploitable by any MCP consumer (they author workflows) against whoever opens the result. No token is needed to serve the file.
    • The route is output_file in dw/server/app.py. .xhtml/.svg are served the same way, but the engine can't write them, so those need a planted file.
  2. The same content through /inputs. High (same root cause).
    • POST /api/assets/keep checks only that the kept name's extension matches the source's. An .html output becomes an .html asset, and /inputs serves it as text/html.
    • Renaming it to .svg is refused.
  3. No Content-Security-Policy on the UI. Medium (defence in depth). Nothing limits what an injected script loads or where it sends the token. A CSP would have contained finding 1.
  4. No X-Frame-Options / frame-ancestors. Medium. Any site can frame the UI and clickjack Run or Delete. The Origin check doesn't help, because a framed page is same-origin.
  5. No X-Content-Type-Options: nosniff on the UI or on /outputs. Low: I found no route where sniffing changes the outcome today.

Fixing finding 1 (either refuse active types in content_type_fault, or serve /outputs and /inputs with attachment, nosniff and CSP: sandbox) also covers finding 2. Which one is a design call, so it's yours.

Already failing on develop

None. On this branch (merged with current develop): pytest 5904 passed, 15 skipped, 11 xfailed; vitest 335 passed; the full Playwright suite 108 passed (the 98 existing plus these 10). eslint, prettier and ruff are clean on the new files.

CI does not run Playwright. It runs prettier and eslint on e2e/, not the specs, so security.spec.ts only runs through npm run e2e / npm run preflight. Worth deciding whether e2e belongs in CI. It needs the Python environment and ~2 minutes.

Environment note: this sandbox has Chromium build 1194 while Playwright 1.62 expects 1234. I aliased them locally only; nothing about that is committed.

Not covered

  • Token hygiene beyond the headers. ?token= on thumbnail, download and SSE URLs lands in server logs and history. It's a documented trade-off, so no test.
  • The Monaco editor's own sanitisation (hovers, markdown in completions): third-party, and package.json already pins dompurify for it.
  • Component-level vitest tests with mocked API data. The real-browser specs cover the same rendering more strongly, and vitest would need a bespoke mock per page.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52


Generated by Claude Code

Comment thread ui/e2e/security.spec.ts Fixed
Planting it with localStorage.setItem from the spec tripped CodeQL's
clear-text-storage rule on a fake value; going through the token popover
is also the path a real user's token takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52
@dkackman
dkackman merged commit 868ff1a into develop Sep 24, 2026
9 checks passed
@dkackman
dkackman deleted the test/security-web branch September 25, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants