From a830cce511c1e13abafcba46f674c86710650c5a Mon Sep 17 00:00:00 2001 From: chen Date: Wed, 22 Jul 2026 04:22:41 +0800 Subject: [PATCH 1/2] test(xss): add Playwright gate for renderMarkdownSafe XSS vectors --- .github/workflows/tests.yml | 30 ++++++ pyproject.toml | 2 + tests/test_xss_browser.py | 163 +++++++++++++++++++++++++++++++++ tests/test_xss_sanitization.py | 10 +- 4 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 tests/test_xss_browser.py 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..8ae7a94 --- /dev/null +++ b/tests/test_xss_browser.py @@ -0,0 +1,163 @@ +""" +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 Any, Generator + +import pytest +from playwright.sync_api import Page, sync_playwright +from werkzeug.serving import make_server + +from app import create_app + +# Representative vectors from the sprint issue. +XSS_VECTORS: list[tuple[str, str]] = [ + ("img_onerror", ''), + ("script_tag", ""), + ("javascript_uri", "[x](javascript:window.__xssProbe=1)"), + ( + "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); + 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, + svgOnload: el.querySelector('svg[onload]') !== null, + }}; + if (!useSafeRender) {{ + result.html = el.innerHTML; + }} + return result; +}} +""" + + +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"