diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 3a34c3d..9ce023b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -132,6 +132,36 @@ jobs:
if: matrix.os == 'windows-latest' && matrix.python-version == '3.12'
run: dist\CursorChatBrowser\CursorChatBrowser.exe --help
+ # ── Browser XSS: Playwright (sprint item #3) ─────────────────────────────
+ browser-xss:
+ name: Browser XSS (Playwright)
+ needs: [lockfile]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.12"
+
+ - name: Install runtime + browser test dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements-lock.txt
+ python -m pip install 'pytest>=8,<9' 'playwright==1.50.0'
+
+ - name: Install Chromium for Playwright
+ run: python -m playwright install chromium --with-deps
+
+ - name: Run headless XSS browser tests
+ run: python -m pytest tests/test_xss_browser.py -v --tb=short -o addopts=
+
+ - name: Run static XSS grep backstop
+ run: python -m unittest tests.test_xss_sanitization -v
+
# ── Typecheck: mypy ───────────────────────────────────────────────────────
# strict = true in pyproject.toml (issue #100). Per-module overrides skip
# scripts/export.py and tests/ until those surfaces are fully annotated.
diff --git a/pyproject.toml b/pyproject.toml
index c9dc62e..05496cb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,7 @@ dev = [
"pytest-benchmark>=4,<5",
"mypy>=1.10,<2",
"hypothesis>=6.100,<7",
+ "playwright==1.50.0",
]
[tool.pytest.ini_options]
@@ -43,6 +44,7 @@ addopts = "--benchmark-skip"
testpaths = ["tests"]
markers = [
"benchmark: performance benchmarks (pytest-benchmark)",
+ "browser: headless browser tests (Playwright)",
]
[project.scripts]
diff --git a/tests/test_xss_browser.py b/tests/test_xss_browser.py
new file mode 100644
index 0000000..d6f4ed8
--- /dev/null
+++ b/tests/test_xss_browser.py
@@ -0,0 +1,199 @@
+"""
+Headless-browser XSS regression tests (issue #11 / sprint item #3).
+
+Exercises the production render path: Marked.js + DOMPurify via
+renderMarkdownSafe() in static/js/app.js, then DOM insertion via innerHTML
+(the same pattern as templates/workspace.html).
+
+Pages are served with the same CSP as production; inline event handlers are
+blocked by the header, so these tests assert dangerous markup is stripped from
+the sink (and use a probe where execution is still observable).
+
+Static source greps live in tests/test_xss_sanitization.py.
+
+Run:
+ playwright install chromium
+ pytest -q tests/test_xss_browser.py
+"""
+
+from __future__ import annotations
+
+import threading
+from typing import TYPE_CHECKING, Any, Generator
+
+import pytest
+from werkzeug.serving import make_server
+
+from app import create_app
+
+if TYPE_CHECKING:
+ from playwright.sync_api import Page
+
+# Representative vectors from the sprint issue.
+XSS_VECTORS: list[tuple[str, str]] = [
+ ("img_onerror", '
'),
+ ("script_tag", ""),
+ ("javascript_uri", "[x](javascript:window.__xssProbe=1)"),
+ (
+ "data_uri",
+ "[x](data:text/html,)",
+ ),
+ (
+ "svg_onload",
+ '',
+ ),
+]
+
+_PROBE_SETTLE_MS = 150
+
+_INSPECT_XSS_SINK = f"""
+async ({{ payload, useSafeRender }}) => {{
+ window.__xssProbe = 0;
+ const host = document.getElementById('xss-browser-test-host');
+ if (host) {{
+ host.remove();
+ }}
+ const el = document.createElement('div');
+ el.id = 'xss-browser-test-host';
+ document.body.appendChild(el);
+ let sanitizeCalls = 0;
+ let restoreSanitize = null;
+ if (useSafeRender) {{
+ const originalSanitize = DOMPurify.sanitize.bind(DOMPurify);
+ DOMPurify.sanitize = (...args) => {{
+ sanitizeCalls += 1;
+ return originalSanitize(...args);
+ }};
+ restoreSanitize = () => {{
+ DOMPurify.sanitize = originalSanitize;
+ }};
+ }}
+ try {{
+ if (useSafeRender) {{
+ if (typeof renderMarkdownSafe !== 'function') {{
+ throw new Error('renderMarkdownSafe is not defined — is app.js loaded?');
+ }}
+ el.innerHTML = renderMarkdownSafe(payload);
+ }} else {{
+ const html = marked.parse(payload, {{ breaks: true, gfm: true }});
+ el.innerHTML = html;
+ }}
+ await new Promise((resolve) => setTimeout(resolve, {_PROBE_SETTLE_MS}));
+ const result = {{
+ probe: window.__xssProbe || 0,
+ onerrorAttr: el.querySelector('[onerror]') !== null,
+ scriptTag: el.querySelector('script') !== null,
+ jsHref: el.querySelector('[href^="javascript:"]') !== null,
+ dataHref: el.querySelector('[href^="data:"]') !== null,
+ svgOnload: el.querySelector('svg[onload]') !== null,
+ sanitizeCalls: useSafeRender ? sanitizeCalls : 0,
+ }};
+ if (!useSafeRender) {{
+ result.html = el.innerHTML;
+ }}
+ return result;
+ }} finally {{
+ if (restoreSanitize) {{
+ restoreSanitize();
+ }}
+ }}
+}}
+"""
+
+
+def _assert_sink_neutralized(result: dict[str, Any], vector_name: str) -> None:
+ assert result["probe"] == 0, (
+ f"XSS probe fired for vector {vector_name!r}; "
+ "renderMarkdownSafe must neutralize this payload"
+ )
+ assert not result["onerrorAttr"], (
+ f"onerror attribute survived sanitization for {vector_name!r}"
+ )
+ assert not result["scriptTag"], (
+ f"