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() + }) +})