Skip to content

Commit b2a4d0d

Browse files
playwright xss tests for renderMarkdownSafe (#151)
* test(xss): add Playwright gate for renderMarkdownSafe XSS vectors * test(xss): lazy-load Playwright for unittest and assert DOMPurify.sanitize runs * add data: URI xss vector to browser regression tests
1 parent 02936aa commit b2a4d0d

4 files changed

Lines changed: 237 additions & 4 deletions

File tree

.github/workflows/tests.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,36 @@ jobs:
132132
if: matrix.os == 'windows-latest' && matrix.python-version == '3.12'
133133
run: dist\CursorChatBrowser\CursorChatBrowser.exe --help
134134

135+
# ── Browser XSS: Playwright (sprint item #3) ─────────────────────────────
136+
browser-xss:
137+
name: Browser XSS (Playwright)
138+
needs: [lockfile]
139+
runs-on: ubuntu-latest
140+
steps:
141+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
142+
with:
143+
persist-credentials: false
144+
145+
- name: Set up Python
146+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
147+
with:
148+
python-version: "3.12"
149+
150+
- name: Install runtime + browser test dependencies
151+
run: |
152+
python -m pip install --upgrade pip
153+
python -m pip install -r requirements-lock.txt
154+
python -m pip install 'pytest>=8,<9' 'playwright==1.50.0'
155+
156+
- name: Install Chromium for Playwright
157+
run: python -m playwright install chromium --with-deps
158+
159+
- name: Run headless XSS browser tests
160+
run: python -m pytest tests/test_xss_browser.py -v --tb=short -o addopts=
161+
162+
- name: Run static XSS grep backstop
163+
run: python -m unittest tests.test_xss_sanitization -v
164+
135165
# ── Typecheck: mypy ───────────────────────────────────────────────────────
136166
# strict = true in pyproject.toml (issue #100). Per-module overrides skip
137167
# scripts/export.py and tests/ until those surfaces are fully annotated.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dev = [
3434
"pytest-benchmark>=4,<5",
3535
"mypy>=1.10,<2",
3636
"hypothesis>=6.100,<7",
37+
"playwright==1.50.0",
3738
]
3839

3940
[tool.pytest.ini_options]
@@ -43,6 +44,7 @@ addopts = "--benchmark-skip"
4344
testpaths = ["tests"]
4445
markers = [
4546
"benchmark: performance benchmarks (pytest-benchmark)",
47+
"browser: headless browser tests (Playwright)",
4648
]
4749

4850
[project.scripts]

tests/test_xss_browser.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""
2+
Headless-browser XSS regression tests (issue #11 / sprint item #3).
3+
4+
Exercises the production render path: Marked.js + DOMPurify via
5+
renderMarkdownSafe() in static/js/app.js, then DOM insertion via innerHTML
6+
(the same pattern as templates/workspace.html).
7+
8+
Pages are served with the same CSP as production; inline event handlers are
9+
blocked by the header, so these tests assert dangerous markup is stripped from
10+
the sink (and use a probe where execution is still observable).
11+
12+
Static source greps live in tests/test_xss_sanitization.py.
13+
14+
Run:
15+
playwright install chromium
16+
pytest -q tests/test_xss_browser.py
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import threading
22+
from typing import TYPE_CHECKING, Any, Generator
23+
24+
import pytest
25+
from werkzeug.serving import make_server
26+
27+
from app import create_app
28+
29+
if TYPE_CHECKING:
30+
from playwright.sync_api import Page
31+
32+
# Representative vectors from the sprint issue.
33+
XSS_VECTORS: list[tuple[str, str]] = [
34+
("img_onerror", '<img src=x onerror="window.__xssProbe=1">'),
35+
("script_tag", "<script>window.__xssProbe=1</script>"),
36+
("javascript_uri", "[x](javascript:window.__xssProbe=1)"),
37+
(
38+
"data_uri",
39+
"[x](data:text/html,<script>window.__xssProbe=1</script>)",
40+
),
41+
(
42+
"svg_onload",
43+
'<svg xmlns="http://www.w3.org/2000/svg" onload="window.__xssProbe=1"></svg>',
44+
),
45+
]
46+
47+
_PROBE_SETTLE_MS = 150
48+
49+
_INSPECT_XSS_SINK = f"""
50+
async ({{ payload, useSafeRender }}) => {{
51+
window.__xssProbe = 0;
52+
const host = document.getElementById('xss-browser-test-host');
53+
if (host) {{
54+
host.remove();
55+
}}
56+
const el = document.createElement('div');
57+
el.id = 'xss-browser-test-host';
58+
document.body.appendChild(el);
59+
let sanitizeCalls = 0;
60+
let restoreSanitize = null;
61+
if (useSafeRender) {{
62+
const originalSanitize = DOMPurify.sanitize.bind(DOMPurify);
63+
DOMPurify.sanitize = (...args) => {{
64+
sanitizeCalls += 1;
65+
return originalSanitize(...args);
66+
}};
67+
restoreSanitize = () => {{
68+
DOMPurify.sanitize = originalSanitize;
69+
}};
70+
}}
71+
try {{
72+
if (useSafeRender) {{
73+
if (typeof renderMarkdownSafe !== 'function') {{
74+
throw new Error('renderMarkdownSafe is not defined — is app.js loaded?');
75+
}}
76+
el.innerHTML = renderMarkdownSafe(payload);
77+
}} else {{
78+
const html = marked.parse(payload, {{ breaks: true, gfm: true }});
79+
el.innerHTML = html;
80+
}}
81+
await new Promise((resolve) => setTimeout(resolve, {_PROBE_SETTLE_MS}));
82+
const result = {{
83+
probe: window.__xssProbe || 0,
84+
onerrorAttr: el.querySelector('[onerror]') !== null,
85+
scriptTag: el.querySelector('script') !== null,
86+
jsHref: el.querySelector('[href^="javascript:"]') !== null,
87+
dataHref: el.querySelector('[href^="data:"]') !== null,
88+
svgOnload: el.querySelector('svg[onload]') !== null,
89+
sanitizeCalls: useSafeRender ? sanitizeCalls : 0,
90+
}};
91+
if (!useSafeRender) {{
92+
result.html = el.innerHTML;
93+
}}
94+
return result;
95+
}} finally {{
96+
if (restoreSanitize) {{
97+
restoreSanitize();
98+
}}
99+
}}
100+
}}
101+
"""
102+
103+
104+
def _assert_sink_neutralized(result: dict[str, Any], vector_name: str) -> None:
105+
assert result["probe"] == 0, (
106+
f"XSS probe fired for vector {vector_name!r}; "
107+
"renderMarkdownSafe must neutralize this payload"
108+
)
109+
assert not result["onerrorAttr"], (
110+
f"onerror attribute survived sanitization for {vector_name!r}"
111+
)
112+
assert not result["scriptTag"], (
113+
f"<script> survived sanitization for {vector_name!r}"
114+
)
115+
assert not result["jsHref"], (
116+
f"javascript: URI survived sanitization for {vector_name!r}"
117+
)
118+
assert not result["dataHref"], (
119+
f"data: URI survived sanitization for {vector_name!r}"
120+
)
121+
assert not result["svgOnload"], (
122+
f"svg onload survived sanitization for {vector_name!r}"
123+
)
124+
assert result.get("sanitizeCalls", 0) >= 1, (
125+
f"DOMPurify.sanitize was not called for {vector_name!r}; "
126+
"renderMarkdownSafe must not take the escapeHtml-only path for these payloads"
127+
)
128+
129+
130+
@pytest.fixture(scope="module")
131+
def playwright_browser():
132+
pytest.importorskip("playwright")
133+
from playwright.sync_api import sync_playwright
134+
135+
with sync_playwright() as playwright:
136+
browser = playwright.chromium.launch(headless=True)
137+
yield browser
138+
browser.close()
139+
140+
141+
@pytest.fixture
142+
def browser_page(playwright_browser) -> Generator["Page", None, None]:
143+
page = playwright_browser.new_page()
144+
try:
145+
yield page
146+
finally:
147+
page.close()
148+
149+
150+
@pytest.fixture
151+
def live_server_url(workspace_storage: str) -> Generator[str, None, None]:
152+
app = create_app()
153+
app.config["TESTING"] = True
154+
app.config["EXCLUSION_RULES"] = []
155+
server = make_server("127.0.0.1", 0, app)
156+
thread = threading.Thread(target=server.serve_forever, daemon=True)
157+
thread.start()
158+
host, port = server.server_address[:2]
159+
try:
160+
yield f"http://{host}:{port}"
161+
finally:
162+
server.shutdown()
163+
164+
165+
@pytest.fixture
166+
def app_page(browser_page: "Page", live_server_url: str) -> "Page":
167+
"""Any HTML page that loads base.html scripts (marked, DOMPurify, app.js)."""
168+
response = browser_page.goto(f"{live_server_url}/", wait_until="networkidle")
169+
assert response is not None and response.ok
170+
browser_page.wait_for_function(
171+
"() => typeof renderMarkdownSafe === 'function' && typeof DOMPurify !== 'undefined'"
172+
)
173+
return browser_page
174+
175+
176+
@pytest.mark.browser
177+
@pytest.mark.parametrize("vector_name,payload", XSS_VECTORS, ids=[v[0] for v in XSS_VECTORS])
178+
def test_render_markdown_safe_neutralizes_xss_vector(
179+
app_page: "Page", vector_name: str, payload: str
180+
) -> None:
181+
result = app_page.evaluate(
182+
_INSPECT_XSS_SINK, {"payload": payload, "useSafeRender": True}
183+
)
184+
_assert_sink_neutralized(result, vector_name)
185+
186+
187+
@pytest.mark.browser
188+
def test_bare_marked_parse_leaves_dangerous_markup_negative_control(
189+
app_page: "Page",
190+
) -> None:
191+
"""Without DOMPurify.sanitize, marked output still carries exploitable markup."""
192+
payload = XSS_VECTORS[0][1]
193+
result = app_page.evaluate(
194+
_INSPECT_XSS_SINK, {"payload": payload, "useSafeRender": False}
195+
)
196+
assert result["onerrorAttr"] or result["scriptTag"], (
197+
"negative control: bare marked.parse should leave at least one dangerous "
198+
"node/attribute in the DOM sink"
199+
)

tests/test_xss_sanitization.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
(workspace.html → innerHTML) or a downloadable HTML blob (download.js).
1010
4. Never call marked.parse(...) without a DOMPurify.sanitize(...) wrap.
1111
12-
These checks are static-source assertions — there is no JS test runner in
13-
this repo, but a future regression that re-introduces a bare marked.parse
14-
call would slip past every dynamic test even if one existed. Source-grep
15-
guards are the cheapest backstop.
12+
These checks are static-source assertions — a future regression that
13+
re-introduces a bare marked.parse call would slip past the headless browser
14+
suite if nobody exercised that path. Source-grep guards are the cheap backstop;
15+
authoritative execution checks live in tests/test_xss_browser.py (Playwright).
1616
1717
Run:
1818
python -m unittest tests.test_xss_sanitization -v
19+
playwright install chromium
20+
pytest -q tests/test_xss_browser.py
1921
"""
2022

2123
import glob

0 commit comments

Comments
 (0)