diff --git a/tests/test_security_web_headers.py b/tests/test_security_web_headers.py
new file mode 100644
index 00000000..d010ee71
--- /dev/null
+++ b/tests/test_security_web_headers.py
@@ -0,0 +1,290 @@
+"""What the browser is told about each response, and what the UI's origin
+can be made to serve.
+
+The web UI keeps the API token in localStorage, and the server serves the
+UI, generated outputs (`/outputs`, no token) and input media (`/inputs`, no
+token) from one origin. So the question for the HTTP layer is not only who
+may call the API but what a browser will *execute* when it loads something
+from that origin - and whether a page elsewhere can frame or read it.
+
+ui/e2e/security.spec.ts drives the same boundary through a real browser;
+these tests pin the headers and content types that decide it, fast and
+without one.
+"""
+
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from dw.security import TRUST_WORKFLOWS_ENV_VAR
+from dw.server.app import create_app
+from dw.server.jobs import JobManager
+
+from .test_server import ScriptedWorkerManager, success_script
+
+# Content types a browser renders as a document, where script runs
+ACTIVE_TYPES = (
+ "text/html",
+ "application/xhtml+xml",
+ "text/xml",
+ "application/xml",
+ "image/svg+xml",
+)
+SCRIPT = ""
+
+
+@pytest.fixture
+def tree(tmp_path):
+ root = tmp_path / "ws"
+ for sub in ("workflows", "outputs", "assets", "prompts"):
+ (root / sub).mkdir(parents=True)
+ ui = tmp_path / "ui"
+ ui.mkdir()
+ (ui / "index.html").write_text("
dw")
+ (root / "outputs" / "run.png").write_bytes(b"\x89PNG\r\n\x1a\n")
+ (root / "outputs" / "note.txt").write_text(SCRIPT)
+ return {"root": root, "ui": ui, "tmp": tmp_path}
+
+
+@pytest.fixture
+def client(tree, monkeypatch):
+ monkeypatch.setenv(TRUST_WORKFLOWS_ENV_VAR, "0")
+ root = tree["root"]
+ manager = JobManager(
+ str(root / "outputs"),
+ worker_manager=ScriptedWorkerManager(success_script),
+ history_path=str(tree["tmp"] / "jobs.sqlite"),
+ )
+ app = create_app(
+ workflow_dir=str(root / "workflows"),
+ output_dir=str(root / "outputs"),
+ job_manager=manager,
+ prompt_dir=str(root / "prompts"),
+ asset_dir=str(root / "assets"),
+ workspace=str(root),
+ ui_dir=str(tree["ui"]),
+ )
+ with TestClient(app, base_url="http://localhost") as test_client:
+ yield test_client
+
+
+def _document_is_inert(response):
+ """A response a browser will not execute as a same-origin document:
+ not an active type, or forced to download, or sandboxed by CSP."""
+ content_type = response.headers.get("content-type", "").split(";")[0].strip()
+ disposition = response.headers.get("content-disposition", "")
+ csp = response.headers.get("content-security-policy", "")
+ return (
+ content_type not in ACTIVE_TYPES
+ or disposition.startswith("attachment")
+ or "sandbox" in csp
+ )
+
+
+# ------------------------------------------------ active content in outputs
+
+
+class TestActiveOutputs:
+ """A result's `content_type` is text/* permissive by design (dw/
+ content_types.py), so a workflow - which an MCP agent may author - can
+ write an .html or .xml output. /outputs needs no token and shares the
+ UI's origin."""
+
+ @pytest.mark.parametrize(
+ "content_type, extension", [("text/html", ".html"), ("text/xml", ".xml")]
+ )
+ def test_the_engine_writes_it(self, tmp_path, monkeypatch, content_type, extension):
+ """The precondition, pinned: this is a real path, not a planted file."""
+ from dw.workflow import Workflow
+
+ monkeypatch.setenv(TRUST_WORKFLOWS_ENV_VAR, "0")
+ definition = {
+ "id": "page",
+ "steps": [
+ {
+ "name": "t",
+ "task": {
+ "command": "compose_text",
+ "arguments": {"parts": [SCRIPT]},
+ },
+ "result": {"content_type": content_type},
+ }
+ ],
+ }
+ workflow = Workflow(definition, str(tmp_path / "out"), "")
+ assert workflow.validation_errors() == []
+ workflow.run({})
+ written = [p for p in (tmp_path / "out").rglob(f"*{extension}")]
+ assert len(written) == 1
+ assert SCRIPT in written[0].read_text()
+
+ @pytest.mark.xfail(
+ strict=True,
+ reason="/outputs (no token) serves an active document type as-is on "
+ "the UI origin, with no attachment disposition or CSP sandbox - a "
+ "workflow writes .html/.xml itself (text/html, text/xml results); "
+ ".xhtml/.svg need a planted file - stored XSS that reads the token",
+ )
+ @pytest.mark.parametrize(
+ "name", ["page.html", "page.xhtml", "page.xml", "page.svg"]
+ )
+ def test_outputs_does_not_serve_it_as_a_live_document(self, client, tree, name):
+ (tree["root"] / "outputs" / name).write_text(SCRIPT)
+ response = client.get(f"/outputs/{name}")
+ assert response.status_code == 200
+ assert _document_is_inert(response), response.headers
+
+ def test_a_text_output_is_served_as_plain_text(self, client):
+ response = client.get("/outputs/note.txt")
+ assert response.headers["content-type"].startswith("text/plain")
+
+ @pytest.mark.parametrize(
+ "name", ["page.html", "page.svg", "page.xml", "page.xhtml"]
+ )
+ def test_an_upload_cannot_plant_one(self, client, name):
+ response = client.post(f"/api/uploads?filename={name}", content=SCRIPT.encode())
+ assert response.status_code == 400
+
+ @pytest.mark.xfail(
+ strict=True,
+ reason="keep_output only checks the kept name's extension matches the "
+ "source's, so an .html output becomes an .html asset that /inputs "
+ "(no token) serves as text/html on the UI origin",
+ )
+ def test_keep_output_cannot_carry_one_into_assets(self, client, tree):
+ (tree["root"] / "outputs" / "page.html").write_text(SCRIPT)
+ response = client.post(
+ "/api/assets/keep", json={"name": "page.html", "asset_name": "cast.html"}
+ )
+ if response.status_code < 400:
+ assert _document_is_inert(client.get("/inputs/cast.html"))
+
+ def test_keep_output_cannot_rename_one_to_svg(self, client, tree):
+ (tree["root"] / "outputs" / "page.html").write_text(SCRIPT)
+ response = client.post(
+ "/api/assets/keep", json={"name": "page.html", "asset_name": "cast.svg"}
+ )
+ assert response.status_code == 400
+ assert not (tree["root"] / "assets" / "cast.svg").exists()
+
+
+# ------------------------------------------------------------ the headers
+
+UI_AND_MEDIA = ["/", "/index.html", "/outputs/run.png", "/outputs/note.txt"]
+
+
+class TestBrowserHeaders:
+ @pytest.mark.xfail(
+ strict=True,
+ reason="no Content-Security-Policy on the UI: nothing limits what a "
+ "script injected into the page may load or where it may send the token",
+ )
+ def test_the_ui_carries_a_content_security_policy(self, client):
+ response = client.get("/")
+ assert response.status_code == 200
+ assert "script-src" in response.headers.get(
+ "content-security-policy", ""
+ ) or "default-src" in response.headers.get("content-security-policy", "")
+
+ @pytest.mark.xfail(
+ strict=True,
+ reason="no X-Frame-Options or CSP frame-ancestors: any site can frame "
+ "the UI (clickjacking the run/delete buttons)",
+ )
+ def test_the_ui_cannot_be_framed(self, client):
+ response = client.get("/")
+ frame_options = response.headers.get("x-frame-options", "").upper()
+ csp = response.headers.get("content-security-policy", "")
+ assert frame_options in ("DENY", "SAMEORIGIN") or "frame-ancestors" in csp
+
+ @pytest.mark.xfail(
+ strict=True,
+ reason="no X-Content-Type-Options: nosniff on UI or media responses",
+ )
+ @pytest.mark.parametrize("path", UI_AND_MEDIA)
+ def test_nosniff(self, client, path):
+ response = client.get(path)
+ assert response.status_code == 200, path
+ assert response.headers.get("x-content-type-options") == "nosniff"
+
+ def test_the_api_answers_json_as_json(self, client):
+ """The one thing that keeps an API body from being rendered as a
+ page today: its declared type."""
+ response = client.get("/api/health")
+ assert response.headers["content-type"].startswith("application/json")
+
+
+# ------------------------------------------------------------------ CORS
+
+
+class TestCrossOriginReads:
+ EVIL = "https://evil.example"
+
+ @pytest.mark.parametrize(
+ "path", ["/api/health", "/api/workflows", "/outputs/run.png", "/"]
+ )
+ def test_no_response_grants_a_foreign_origin_read_access(self, client, path):
+ response = client.get(path, headers={"Origin": self.EVIL})
+ assert "access-control-allow-origin" not in response.headers
+ assert "access-control-allow-credentials" not in response.headers
+
+ def test_a_preflight_from_a_foreign_origin_is_refused(self, client):
+ response = client.options(
+ "/api/jobs",
+ headers={
+ "Origin": self.EVIL,
+ "Access-Control-Request-Method": "POST",
+ "Access-Control-Request-Headers": "authorization,content-type",
+ },
+ )
+ assert response.status_code >= 400
+ assert "access-control-allow-origin" not in response.headers
+
+ def test_a_same_origin_page_is_not_refused(self, client):
+ response = client.get("/api/health", headers={"Origin": "http://localhost"})
+ assert response.status_code == 200
+
+
+# ------------------------------------------------- download file names
+
+
+class TestDownloadNames:
+ """A file name on disk ends up in a Content-Disposition header. It must
+ not be able to add a header or break out of the quoted value."""
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ 'quote"; filename="evil.html.png',
+ "semi;colon.png",
+ "unicode-txt.png",
+ "crlf\r\nX-Injected: 1.png",
+ "lf\nSet-Cookie: dw=1.png",
+ ],
+ )
+ def test_a_hostile_name_cannot_inject_a_header(self, client, tree, name):
+ try:
+ (tree["root"] / "outputs" / name).write_bytes(b"\x89PNG")
+ except OSError:
+ pytest.skip("this filesystem refuses the name")
+ from urllib.parse import quote
+
+ response = client.get(f"/api/gallery/{quote(name)}/download")
+ assert "x-injected" not in response.headers
+ assert "set-cookie" not in response.headers
+ disposition = response.headers.get("content-disposition", "")
+ assert "\r" not in disposition and "\n" not in disposition
+ # the name, however spelled, is one parameter, not a second filename=
+ assert disposition.count("filename=") <= 1
+
+ def test_the_gallery_listing_reports_a_hostile_name_as_data(self, client, tree):
+ """Escaping is the UI's job (ui/e2e/security.spec.ts); the API's is
+ to report the name exactly, inside JSON, so nothing is pre-rendered."""
+ name = "x\"'>.png"
+ (tree["root"] / "outputs" / name).write_bytes(b"\x89PNG")
+ response = client.get("/api/gallery")
+ assert response.headers["content-type"].startswith("application/json")
+ names = [entry["name"] for entry in response.json()["files"]]
+ assert name in names
+ json.dumps(names) # round-trips as plain data
diff --git a/ui/e2e/security.spec.ts b/ui/e2e/security.spec.ts
new file mode 100644
index 00000000..58f7a41d
--- /dev/null
+++ b/ui/e2e/security.spec.ts
@@ -0,0 +1,255 @@
+import {
+ expect,
+ test,
+ type APIRequestContext,
+ type Page,
+} from '@playwright/test'
+import * as fs from 'node:fs'
+import * as path from 'node:path'
+
+/* Hostile content, a real server and a real browser.
+ *
+ * Everything the UI renders about a workflow, a prompt, a file or a job is
+ * text somebody else chose - an MCP agent authoring a workflow, a file name
+ * on disk, the output of a run. The UI keeps the API token in localStorage,
+ * so a script that runs on this origin can read it. These specs plant
+ * payloads in every field an untrusted author controls, walk the pages
+ * that show them, and fail if any payload executes.
+ *
+ * Execution is detected, not inferred: every payload sets
+ * `document.documentElement.dataset.xss` (and an alert would show up as a
+ * dialog), and each page check first waits for the payload to be *visible
+ * as text* - so a page that simply failed to render cannot pass.
+ *
+ * Content lives in its own workspace (e2e-xss) and one shared prompt, both
+ * removed in afterAll, so the other specs never see it. Files on disk go
+ * into the workspace directory the server reports - the spec runs on the
+ * same machine as the fixture server. */
+
+const WS = 'e2e-xss'
+const PROMPT = 'e2e-xss-probe'
+const MARK = (id: string) => `document.documentElement.dataset.xss='${id}'`
+
+// For JSON fields: every shape an HTML sink would execute
+const PAYLOAD = (id: string) =>
+ `"'>` +
+ `javascript:${MARK(id)}`
+// For file names: no '/', so no closing tags
+const FILE_PAYLOAD = `x"'>`
+
+// A 1x1 PNG, the same bytes serve_fixture.py writes
+const PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmM' +
+ 'IQAAAABJRU5ErkJggg==',
+ 'base64',
+)
+
+const hostileWorkflow = (id: string) => ({
+ id,
+ description: PAYLOAD('description'),
+ variables: { note: PAYLOAD('default') },
+ steps: [
+ {
+ name: 'compose',
+ task: {
+ command: 'compose_text',
+ arguments: { parts: ['variable:note', PAYLOAD('argument')] },
+ },
+ result: { content_type: 'text/plain' },
+ },
+ ],
+})
+
+let outputsDir = ''
+let assetsDir = ''
+let htmlOutput = ''
+let htmlJobId = ''
+
+async function waitForJob(request: APIRequestContext, id: string) {
+ for (let i = 0; i < 240; i++) {
+ const job = await (await request.get(`/api/jobs/${id}`)).json()
+ if (['succeeded', 'failed', 'cancelled'].includes(job.status)) return job
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ }
+ throw new Error(`job ${id} did not finish`)
+}
+
+test.beforeAll(async ({ request }) => {
+ // the first job spawns the worker, which imports torch
+ test.setTimeout(180_000)
+ const created = await request.post('/api/workspaces', { data: { name: WS } })
+ expect([201, 409]).toContain(created.status())
+ const server = await (await request.get(`/api/server?workspace=${WS}`)).json()
+ outputsDir = server.directories.outputs
+ assetsDir = server.directories.assets
+ expect(outputsDir).toContain(WS)
+
+ // names the server itself will list: gallery files and assets
+ const outputs = path.join(outputsDir, 'hostile')
+ fs.mkdirSync(outputs, { recursive: true })
+ fs.writeFileSync(path.join(outputs, `${FILE_PAYLOAD}.png`), PNG)
+ fs.writeFileSync(path.join(outputs, 'note.txt'), PAYLOAD('text-output'))
+ const assets = assetsDir
+ fs.mkdirSync(assets, { recursive: true })
+ fs.writeFileSync(path.join(assets, `${FILE_PAYLOAD}.png`), PNG)
+
+ // what an MCP agent can author
+ const saved = await request.put(`/api/workflows/hostile?workspace=${WS}`, {
+ data: { workflow: hostileWorkflow('hostile') },
+ })
+ expect(saved.ok()).toBeTruthy()
+ const prompt = await request.put(`/api/prompts/${PROMPT}`, {
+ data: {
+ prompt: {
+ text: PAYLOAD('prompt-text'),
+ description: PAYLOAD('prompt-description'),
+ tags: [PAYLOAD('prompt-tag').slice(0, 60)],
+ },
+ },
+ })
+ expect(prompt.ok()).toBeTruthy()
+
+ // a job whose run wrote text/html - a content type the engine accepts
+ const job = await request.post(`/api/jobs?workspace=${WS}`, {
+ data: {
+ workflow: {
+ id: 'hostile-html',
+ steps: [
+ {
+ name: 'page',
+ task: {
+ command: 'compose_text',
+ arguments: {
+ parts: [
+ ``,
+ ],
+ },
+ },
+ result: { content_type: 'text/html' },
+ },
+ ],
+ },
+ },
+ })
+ expect(job.ok()).toBeTruthy()
+ htmlJobId = (await job.json()).id
+ const finished = await waitForJob(request, htmlJobId)
+ expect(finished.status).toBe('succeeded')
+ const written = fs
+ .readdirSync(outputsDir, {
+ recursive: true,
+ })
+ .map(String)
+ .find((name) => name.endsWith('.html'))
+ expect(written).toBeTruthy()
+ htmlOutput = written!
+})
+
+test.afterAll(async ({ request }) => {
+ await request.delete(`/api/prompts/${PROMPT}`)
+ await request.delete(`/api/workspaces/${WS}?acknowledged=true`)
+})
+
+/** Open a page and fail if anything planted ran. `visible` is text the
+ * page must show first, so an empty page is a failure, not a pass. */
+async function assertInert(page: Page, hash: string, visible: RegExp) {
+ const dialogs: string[] = []
+ page.on('dialog', async (dialog) => {
+ dialogs.push(dialog.message())
+ await dialog.dismiss()
+ })
+ await page.goto(hash)
+ await expect(page.getByText(visible).first()).toBeVisible({
+ timeout: 20_000,
+ })
+ // give onerror/onload handlers of broken images a chance to fire
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(300)
+ const fired = await page.evaluate(
+ () => document.documentElement.dataset.xss ?? null,
+ )
+ expect(fired, `payload executed on ${hash}`).toBeNull()
+ expect(dialogs).toEqual([])
+ // nothing the payload spelled became a live element or link
+ expect(await page.locator('img[src="x"]').count()).toBe(0)
+ expect(await page.locator('[onerror], [onload]').count()).toBe(0)
+ expect(await page.locator('a[href^="javascript:" i]').count()).toBe(0)
+}
+
+// Each page, and the literal text it has to show to prove it rendered
+const PAGES: [string, string, RegExp][] = [
+ ['workflow catalog', `/#/ws/${WS}/workflows`, /onerror=/],
+ ['workflow page', `/#/ws/${WS}/workflows/hostile`, /onerror=/],
+ ['gallery', `/#/ws/${WS}/gallery`, /onerror=/],
+ ['assets', `/#/ws/${WS}/assets`, /onerror=/],
+ ['prompt library', '/#/shared/prompts', /e2e-xss-probe/],
+]
+
+for (const [label, hash, visible] of PAGES) {
+ test(`hostile content on the ${label} stays text`, async ({ page }) => {
+ await assertInert(page, hash, visible)
+ })
+}
+
+test('hostile content in the prompt editor stays text', async ({ page }) => {
+ // the editor shows the text inside form fields, not as page text
+ await assertInert(page, `/#/shared/prompt-edit/${PROMPT}`, /e2e-xss-probe/)
+ await expect(
+ page.getByRole('textbox', { name: 'text', exact: true }),
+ ).toHaveValue(/onerror=/)
+})
+
+test('hostile content in the workflow editor stays text', async ({ page }) => {
+ test.setTimeout(60_000)
+ await assertInert(page, `/#/ws/${WS}/edit/hostile`, /hostile/)
+})
+
+test('the job page lists the html output as an inert link', async ({
+ page,
+}) => {
+ await assertInert(page, `/#/ws/${WS}/jobs/${htmlJobId}`, /hostile-html/)
+ await expect(page.locator('a.filelink').first()).toBeVisible()
+})
+
+test('a gallery file with a hostile name opens inert', async ({ page }) => {
+ await assertInert(page, `/#/ws/${WS}/gallery`, /onerror=/)
+ await page
+ .getByText(/onerror=/)
+ .first()
+ .click()
+ await page.waitForTimeout(500)
+ expect(
+ await page.evaluate(() => document.documentElement.dataset.xss ?? null),
+ ).toBeNull()
+})
+
+test.describe('a run that writes text/html', () => {
+ // test.fail: the strict-xfail of Playwright - it fails the suite if this
+ // starts passing, so a fix is noticed and the marker removed
+ test.fail(
+ true,
+ 'finding: a workflow with result content_type "text/html" writes an ' +
+ '.html output that /outputs serves as text/html on the UI origin, ' +
+ 'where its script reads the API token from localStorage',
+ )
+
+ test('opening the output does not run it on the UI origin', async ({
+ page,
+ }) => {
+ // entered the way a user does, through the header's token popover -
+ // the UI is what puts the token where the output's script reads it
+ await page.goto(`/#/ws/${WS}/overview`)
+ await page.getByRole('button', { name: 'API token' }).click()
+ await page.getByPlaceholder('API token').fill('secret-probe')
+ await page.getByRole('button', { name: 'Save' }).click()
+ await expect(page.getByRole('button', { name: 'Saved' })).toBeVisible()
+ await page.goto(`/outputs/${htmlOutput}?workspace=${WS}`)
+ await page.waitForLoadState('load')
+ const title = await page.title()
+ const fired = await page.evaluate(
+ () => document.documentElement.dataset.xss ?? null,
+ )
+ expect(title).not.toContain('secret-probe')
+ expect(fired).toBeNull()
+ })
+})