1919from __future__ import annotations
2020
2121import threading
22- from typing import Any , Generator
22+ from typing import TYPE_CHECKING , Any , Generator
2323
2424import pytest
25- from playwright .sync_api import Page , sync_playwright
2625from werkzeug .serving import make_server
2726
2827from app import create_app
2928
29+ if TYPE_CHECKING :
30+ from playwright .sync_api import Page
31+
3032# Representative vectors from the sprint issue.
3133XSS_VECTORS : list [tuple [str , str ]] = [
3234 ("img_onerror" , '<img src=x onerror="window.__xssProbe=1">' ),
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" )
98123def 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 ])
142170def 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
152180def 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