Skip to content

Commit c5c4f6a

Browse files
test(xss): lazy-load Playwright for unittest and assert DOMPurify.sanitize runs
1 parent a830cce commit c5c4f6a

1 file changed

Lines changed: 52 additions & 24 deletions

File tree

tests/test_xss_browser.py

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@
1919
from __future__ import annotations
2020

2121
import threading
22-
from typing import Any, Generator
22+
from typing import TYPE_CHECKING, Any, Generator
2323

2424
import pytest
25-
from playwright.sync_api import Page, sync_playwright
2625
from werkzeug.serving import make_server
2726

2827
from app import create_app
2928

29+
if TYPE_CHECKING:
30+
from playwright.sync_api import Page
31+
3032
# Representative vectors from the sprint issue.
3133
XSS_VECTORS: list[tuple[str, str]] = [
3234
("img_onerror", '<img src=x onerror="window.__xssProbe=1">'),
@@ -50,27 +52,46 @@
5052
const el = document.createElement('div');
5153
el.id = 'xss-browser-test-host';
5254
document.body.appendChild(el);
55+
let sanitizeCalls = 0;
56+
let restoreSanitize = null;
5357
if (useSafeRender) {{
54-
if (typeof renderMarkdownSafe !== 'function') {{
55-
throw new Error('renderMarkdownSafe is not defined — is app.js loaded?');
56-
}}
57-
el.innerHTML = renderMarkdownSafe(payload);
58-
}} else {{
59-
const html = marked.parse(payload, {{ breaks: true, gfm: true }});
60-
el.innerHTML = html;
58+
const originalSanitize = DOMPurify.sanitize.bind(DOMPurify);
59+
DOMPurify.sanitize = (...args) => {{
60+
sanitizeCalls += 1;
61+
return originalSanitize(...args);
62+
}};
63+
restoreSanitize = () => {{
64+
DOMPurify.sanitize = originalSanitize;
65+
}};
6166
}}
62-
await new Promise((resolve) => setTimeout(resolve, {_PROBE_SETTLE_MS}));
63-
const result = {{
64-
probe: window.__xssProbe || 0,
65-
onerrorAttr: el.querySelector('[onerror]') !== null,
66-
scriptTag: el.querySelector('script') !== null,
67-
jsHref: el.querySelector('[href^="javascript:"]') !== null,
68-
svgOnload: el.querySelector('svg[onload]') !== null,
69-
}};
70-
if (!useSafeRender) {{
71-
result.html = el.innerHTML;
67+
try {{
68+
if (useSafeRender) {{
69+
if (typeof renderMarkdownSafe !== 'function') {{
70+
throw new Error('renderMarkdownSafe is not defined — is app.js loaded?');
71+
}}
72+
el.innerHTML = renderMarkdownSafe(payload);
73+
}} else {{
74+
const html = marked.parse(payload, {{ breaks: true, gfm: true }});
75+
el.innerHTML = html;
76+
}}
77+
await new Promise((resolve) => setTimeout(resolve, {_PROBE_SETTLE_MS}));
78+
const result = {{
79+
probe: window.__xssProbe || 0,
80+
onerrorAttr: el.querySelector('[onerror]') !== null,
81+
scriptTag: el.querySelector('script') !== null,
82+
jsHref: el.querySelector('[href^="javascript:"]') !== null,
83+
svgOnload: el.querySelector('svg[onload]') !== null,
84+
sanitizeCalls: useSafeRender ? sanitizeCalls : 0,
85+
}};
86+
if (!useSafeRender) {{
87+
result.html = el.innerHTML;
88+
}}
89+
return result;
90+
}} finally {{
91+
if (restoreSanitize) {{
92+
restoreSanitize();
93+
}}
7294
}}
73-
return result;
7495
}}
7596
"""
7697

@@ -92,18 +113,25 @@ def _assert_sink_neutralized(result: dict[str, Any], vector_name: str) -> None:
92113
assert not result["svgOnload"], (
93114
f"svg onload survived sanitization for {vector_name!r}"
94115
)
116+
assert result.get("sanitizeCalls", 0) >= 1, (
117+
f"DOMPurify.sanitize was not called for {vector_name!r}; "
118+
"renderMarkdownSafe must not take the escapeHtml-only path for these payloads"
119+
)
95120

96121

97122
@pytest.fixture(scope="module")
98123
def playwright_browser():
124+
pytest.importorskip("playwright")
125+
from playwright.sync_api import sync_playwright
126+
99127
with sync_playwright() as playwright:
100128
browser = playwright.chromium.launch(headless=True)
101129
yield browser
102130
browser.close()
103131

104132

105133
@pytest.fixture
106-
def browser_page(playwright_browser) -> Generator[Page, None, None]:
134+
def browser_page(playwright_browser) -> Generator["Page", None, None]:
107135
page = playwright_browser.new_page()
108136
try:
109137
yield page
@@ -127,7 +155,7 @@ def live_server_url(workspace_storage: str) -> Generator[str, None, None]:
127155

128156

129157
@pytest.fixture
130-
def app_page(browser_page: Page, live_server_url: str) -> Page:
158+
def app_page(browser_page: "Page", live_server_url: str) -> "Page":
131159
"""Any HTML page that loads base.html scripts (marked, DOMPurify, app.js)."""
132160
response = browser_page.goto(f"{live_server_url}/", wait_until="networkidle")
133161
assert response is not None and response.ok
@@ -140,7 +168,7 @@ def app_page(browser_page: Page, live_server_url: str) -> Page:
140168
@pytest.mark.browser
141169
@pytest.mark.parametrize("vector_name,payload", XSS_VECTORS, ids=[v[0] for v in XSS_VECTORS])
142170
def test_render_markdown_safe_neutralizes_xss_vector(
143-
app_page: Page, vector_name: str, payload: str
171+
app_page: "Page", vector_name: str, payload: str
144172
) -> None:
145173
result = app_page.evaluate(
146174
_INSPECT_XSS_SINK, {"payload": payload, "useSafeRender": True}
@@ -150,7 +178,7 @@ def test_render_markdown_safe_neutralizes_xss_vector(
150178

151179
@pytest.mark.browser
152180
def test_bare_marked_parse_leaves_dangerous_markup_negative_control(
153-
app_page: Page,
181+
app_page: "Page",
154182
) -> None:
155183
"""Without DOMPurify.sanitize, marked output still carries exploitable markup."""
156184
payload = XSS_VECTORS[0][1]

0 commit comments

Comments
 (0)