Skip to content

Extend the XSS PR guardrail to the V2 React/TypeScript UI - #1576

Merged
Paul Lizer (paullizer) merged 2 commits into
microsoft:paullizer-react-v2-uifrom
paullizer:paullizer-xss-guardrail-v2-typescript
Sep 29, 2026
Merged

Paul Lizer (paullizer) merged 2 commits into
microsoft:paullizer-react-v2-uifrom
paullizer:paullizer-xss-guardrail-v2-typescript

Conversation

@paullizer

@paullizer Paul Lizer (paullizer) commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The XSS PR guardrail (scripts/check_xss_sinks.py and xss-sink-check.yml) only read .js, .html and .py files, and none of the PR guardrail workflows ran on PRs into paullizer-react-v2-ui. So the V2 React/TypeScript UI (463 files, about 143k lines in application/v2_ui/src) had no XSS sink check at all, and V2 PRs got only enforce-branch-flow and the CLA check.

This PR:

  • teaches the checker .ts, .tsx, .jsx and .mjs with 11 React-aware rules. A small TypeScript/JSX lexer blanks comments, strings, template chunks, regex bodies and JSX text, so rules match real code and not type annotations or comments.
  • runs the six PR guardrail workflows on PRs into paullizer-react-v2-ui. Each entry is marked temporary until V2 merges into Development.
  • adds xss-full-scan.yml, an on-demand whole-file scan that mirrors broken-access-control-full-scan.yml.
  • updates the XSS instruction (applyTo plus a React/TypeScript section), the audit prompt, the feature doc and two related docs.
  • records a reviewed baseline: the tuned rules report 28 findings in the current V2 source, and all 28 are safe.

This is tooling only: no application/ change, no VERSION bump and no release-notes entry. That matches the SHA-pinning and CodeQL-PR-pipeline precedents.

Rules added

All rules apply to .ts, .tsx, .jsx and .mjs. Declaration files (.d.ts, .d.mts, .d.cts) and application/v2_ui/public/vendor/ are skipped. Suppress a finding with xss-check: ignore <reason> on the same line. In these files a bare token without a reason does not suppress.

Rule id Flags Accepts
ts-dangerous-inner-html dangerouslySetInnerHTML whose __html isn't a sanitized value DOMPurify.sanitize(...), sanitize* or escape*Html helpers, literals, and a useMemo that returns one of these
ts-html-sink innerHTML/outerHTML assignment, insertAdjacentHTML, document.write, createContextualFragment, and DOMParser output inserted into the DOM the same sanitized values
ts-code-eval eval, new Function, and a string first argument to setTimeout/setInterval function arguments
ts-javascript-url a javascript: scheme inside a string or template literal a literal that is only the bare scheme, as used in blocklists
ts-jsx-url-attribute dynamic href, xlinkHref or formAction; srcDoc; src on script/iframe/frame/embed; action on <form>; data on <object>; to on react-router Link/NavLink/Navigate literals and same-origin paths; safe template prefixes followed by encodeURIComponent segments; sanitize*/normalize*/safe* URL helpers; reviewed builders; isSafeOriginHref or ^https?:// guards; upper-case constants
ts-navigation-sink window.open, location.href =, location =, and location.assign/replace with a dynamic value the same URL sources, plus statement guards such as if (!ok) return;
ts-markdown-raw-html rehype-raw, allowDangerousHtml, or an identity urlTransform defaultUrlTransform and sanitize*/safe*/normalize* transforms
ts-dom-url-property DOM .href, .src, .action, .formAction and .srcdoc assignments, and setAttribute(...) for those attributes, with a dynamic value the same URL sources; image and media receivers for src; .action only on form receivers
ts-inline-event-handler inline on*= handlers in HTML strings, and inline-handler APIs (port of the classic check)
ts-attribute-interpolation ${...} interpolated into href/src/title/style/data-* attributes of HTML strings the classic allowances (port of the classic check)
ts-marked-parse marked.parse(...) without DOMPurify.sanitize( nearby (port of the classic check)

The existing .js, .html and .py checks are unchanged, and the four original self-tests pass unmodified. I compared the old and new checker on all 2,881 tracked classic files. The old one reports 498 findings and the new one 472. The only difference is 26 findings in the vendored DOMPurify (1) and Mermaid (25) bundles under application/v2_ui/public/vendor/, which are now skipped.

Baseline triage (counts)

Full mode (no SHAs) over the 462 inspected V2 files:

Rule id Raw First pass Tuned
ts-dangerous-inner-html 4 4 4
ts-html-sink 1 1 1
ts-code-eval 0 0 0
ts-javascript-url 3 3 3
ts-jsx-url-attribute 32 15 14
ts-navigation-sink 8 4 4
ts-markdown-raw-html 0 0 0
ts-dom-url-property 11 8 2
ts-inline-event-handler 0 0 0
ts-attribute-interpolation 0 0 0
ts-marked-parse 0 0 0
Total 59 35 28
  • Raw turns off every approval, guard and suppression, so it measures the size of the surface. First pass is the rule set as first designed. Tuned is what the checker reports now.
  • All 28 tuned findings are safe (28 safe, 0 unsafe), and none is an exploitable XSS.
    • 4 dangerouslySetInnerHTML sites sanitize KaTeX (trust: false) or Mermaid (securityLevel: 'strict') output with DOMPurify. test_v2_rich_rendering.py already guards that boundary.
    • 3 are deliberate test samples in the dev-only Alert Lab.
    • 21 are URLs traced to server-built paths, ^https?:// or isSafeOriginHref guards, encoded ids, or fixed constants.
  • Every row, with its one-line reason, is in docs/explanation/features/v0.241.022/XSS_PR_GUARDRAILS_V2_BASELINE_TRIAGE.md, along with the tuning table and the lexer audit.
  • Reconciliation with the coordinator's scan:
    • The lexer finds 56 dynamic href/src/action/formAction JSX attributes in 42 files (the quick scan found 53 in 40). Of those, 22 are <img src> (not a script sink), 16 are action props on custom components (none is on a <form>), 16 are <a href>, 1 is an <iframe src> and 1 is an image-canvas src.
    • The file count is 463 in src, not 464. vite.config.ts sits outside src, and two declaration files are skipped.
  • Lexer audit: 464 files, about 2 s, 19,763 JSX attributes. No file needed the degraded fallback, no JSX retries, and brackets balance in every file.

Workflows

Workflow Change Blocking on V2 PRs
xss-sink-check.yml V2 added to pull_request.branches; application/**/*.ts, *.tsx, *.jsx and *.mjs added to the paths filter and the xss_surface list; xss-full-scan.yml added to the guardrail paths Scan step: yes. Self-test step: advisory (unchanged)
broken-access-control-check.yml V2 added to pull_request.branches yes
swagger-route-check.yml V2 added to pull_request.branches yes
python-syntax-check.yml V2 added to pull_request.branches yes
malicious-pr-security-review.yml V2 added to pull_request.branches blockers only (unchanged)
codeql.yml V2 added to push and pull_request (block style, so the comment fits) per repository code-scanning settings
xss-full-scan.yml (new) workflow_dispatch only: target_paths (default application), fail_on_findings (default false), run_self_tests (default true), and the xss-full-scan-report artifact. All actions are pinned to full SHAs n/a
  • Every V2 entry carries # Temporary: remove when paullizer-react-v2-ui merges into Development (#1571). The self-test fails if a workflow names the V2 branch without that comment.
  • Fork PRs: recent PRs from paullizer/simplechat into Development (Fix group document filename XSS in Share controls and cards #1500, Revert accidental OneNote rollout from Development #1538, Add admin defaults for new chat model and reasoning effort #1520) ran all six workflows, including CodeQL's SARIF upload, and passed. So none had to be left out.
  • Precedent: d89707852 ("Run the quality gates on PRs into the IA staging branch") did the same for feature/admin-settings-ia on the same six workflows, and a5ed90f55 reverted it before the final merge.
  • CodeQL on push: that precedent left CodeQL's push alone. The coordinator's review asked for both push and pull_request here, which also gives V2 PRs a base-branch analysis to compare against.
  • Not changed:
    • release-notes-check.yml stays Development-only, as instructed. It also fails on fork PRs for token reasons.
    • The staging, publish and enforce-branch-flow workflows are unchanged.
    • Existing tag-ref uses: lines are left for Pin GitHub Actions to full-length commit SHAs #1474.
  • GitHub offers a workflow_dispatch workflow only once the file is on the default branch (main). Until then, the feature doc and audit prompt give the local batched command.

What changes for V2 PRs

Validation

All local runs were on Windows, one process at a time.

  • venv is the pinned test venv (Python 3.12.10) with PYTHONPATH=application/single_app;functional_tests and PYTHONIOENCODING=utf-8.
  • global is the system Python 3.12.10 with no PYTHONPATH, which is how the workflow invokes the self-test.
Command Runner Result
python functional_tests/test_xss_guardrails_checker.py venv 12/12
python -O functional_tests/test_xss_guardrails_checker.py venv 12/12
python functional_tests/test_xss_guardrails_checker.py global 12/12
Mutation probe (session artifact): break the checker in memory and rerun the new tests venv, normal and -O 8/8 mutations caught in both modes (the new checks raise explicitly)
test_chat_sidebar_conversations_escape_html.py venv 3/3
route_tests/test_route_policy_test_coverage.py, test_route_blueprint_policy_inventory.py, test_route_unauthenticated_policy_contract.py venv 2/2, 8/8, 4/4
test_broken_access_control_guardrails_checker.py venv 3/4, the same as a git archive export of c33ad4d16. Pre-existing: it asserts actions/upload-artifact@v4, but the base pins v7.0.1
test_swagger_route_pr_workflow.py venv 4/5, the same as the base. Pre-existing: it asserts tj-actions/changed-files@v46.0.1, but the base pins v47.0.6
test_malicious_pr_security_review_checker.py venv 3/4, the same as the base. Pre-existing: it asserts actions/upload-artifact@v4, but the base uses @v7
test_docs_app_surface_coverage.py, test_docs_site_quality.py venv 7/7, 6/6
test_docs_link_integrity.py venv 2/5, the known base result; none of the failures involve these docs
YAML parse of the 7 edited or new workflows venv (PyYAML) all parse; no BOM; LF in the index; the new workflow is fully SHA-pinned
git diff --cached --check git clean
Full mode over V2 (462 files) venv 28 findings, all triaged safe
Full mode over all of application/ (1,288 files, 7 batches of 200) venv 383 findings: the 28 V2 findings plus 355 pre-existing classic findings; about 20 s
Diff mode on a synthetic commit (in the self-test, against a real temporary Git repo) venv and global only the added multi-line href is reported; full mode reports both lines; a comment-only change reports nothing
Diff mode over real V2 history e31772eb0..c33ad4d16 (72 commits, 85 changed files, 57 of them TS-family) venv 3 findings, all the dev-only Alert Lab samples added in 8e9586844; about 12 s
scripts/check_malicious_pr_security_review.py --base-sha c33ad4d16 --head-sha HEAD --verify-release-age venv 0 blockers. The 310 Important and 78 Moderate findings are keyword heuristics, expected in an XSS checker that names eval, Function and sanitizers. The 7 Low are pre-existing tag refs in codeql.yml and malicious-pr-security-review.yml (#1474)
Coordinator's run_v2_guardrails.py <worktree> c33ad4d16 HEAD 1571 venv 6/6 PASS

Checks on this PR

The six workflows ran on this fork PR into paullizer-react-v2-ui, so the new triggers work end to end for cross-repo PRs.

First run, head f77c3eedd (merge commit 0e30f8690, base 2f8dab475, the V2 tip):

Check Result
xss-sink-check pass (21 s). No application files changed, so the scan step exited 0. The advisory self-test ran on Linux with Python 3.12.14: 12/12
broken-access-control-check pass (18 s). No application files changed. The advisory self-test is 3/4, the pre-existing upload-artifact@v4 assertion
swagger-route-check pass (1 m 1 s). No application Python files changed. Route-policy tests 8/8, 4/4 and 2/2
syntax-check pass (30 s). All Python files compiled
malicious-pr-security-review pass (18 s). "No blocker findings detected in reviewed scope."
CodeQL Analyze (actions / javascript-typescript / python) pass (47 s / 3 m 1 s / 11 m 49 s)
CodeQL pass, with 1 new note (see CodeQL triage)
enforce-branch-flow, license/cla pass

Second run, head f5e5cc758 (the CodeQL fix; merge commit 2097aeb71, same base): every check passed again. The XSS self-test is 12/12, and CodeQL reports no new alerts in code changed by this pull request, with 0 annotations. Analyze took 54 s / 3 m 6 s / 9 m 47 s.

CodeQL triage

  • 1 new alert, note severity: py/empty-except on the except OSError: pass in the self-test's remove_tree helper (functional_tests/test_xss_guardrails_checker.py), which I added in this PR. The permission reset is best effort, because shutil.rmtree(..., ignore_errors=True) runs right after it. f5e5cc758 adds a comment saying so, and the self-test still passes 12/12 normally and under -O. On the second run, the CodeQL check shows no new alerts.
  • No alerts on scripts/check_xss_sinks.py or the edited workflows. The workflow runs the security-and-quality suite, which includes the ReDoS queries.
  • The only other annotations are GitHub's generic notice that the ubuntu-latest label moves to Ubuntu 26. They aren't code-scanning alerts.
  • The code-scanning alerts API returns 403 for my token, so I read the CodeQL check run and its annotations instead. The full branch view is under Security → Code scanning, filtered to pr:1576.

Decisions made autonomously

Scope

  • <img src> and other media src values aren't checked, because an image source can't run script. src on script/iframe/frame/embed and data on <object> are checked strictly.
  • Declaration files are skipped.
  • application/v2_ui/public/vendor/ joins the vendored-asset skip list. That also removes 26 pre-existing classic findings in the vendored bundles.
  • .mjs goes through the TypeScript pipeline with JSX off. .js stays on the classic pipeline, even under application/v2_ui/.
  • ts-dom-url-property goes beyond the issue's list and covers DOM .href/.src assignments and setAttribute. React-router to is covered too.
  • The classic inline-handler, attribute-interpolation and marked.parse checks are ported. jQuery .html( isn't checked in TypeScript files, because V2 has no jQuery.

Precision

  • Fail closed: unresolved values are flagged.
  • HTML sources: the TypeScript HTML allowlist is stricter than the classic one, and a sanitized value is followed only through useMemo returns.
  • Reviewed URL builders: 19 same-origin builders, plus emailDraftMailtoUrl for mailto: URLs in href contexts only. Phase 4's new workflowProposalLink isn't pre-approved: approving a helper that isn't on V2 yet would let later edits to it skip review. The two findings it causes are traced safe above.
  • Name-based helpers: helpers named sanitize*, normalize* or safe* and ending in Url/Href/Src/Uri are trusted, following the repo's existing philosophy. Upper-case constants and apiUrl are trusted too.
  • Link-level exceptions:
    • A protocol-relative // is accepted in link contexts, because it's an open-redirect concern, not XSS.
    • URL.createObjectURL is accepted in link contexts only.
    • encodeURIComponent(...) is accepted only after a safe prefix.
  • Refinements: form receivers, statement guards, useState setter tracking and block scoping. Following parameters to their call sites was left out, and the few parameter-driven findings were traced by hand.

Suppression

  • In TypeScript-family files the token needs a same-line justification. Classic behavior is unchanged.

Workflows

  • CodeQL runs on push as well as PRs.
  • fail_on_findings defaults to false, because full mode reports 355 pre-existing classic findings.
  • xss-full-scan.yml changes trigger the XSS self-test.
  • The pre-existing tag refs are left alone.
  • CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md L54–58 is now stale, but that doc is left to the coordinator.

Docs

  • The documented python scripts/check_xss_sinks.py --full-file $xssFiles exceeds Windows' 32,767-character command-line limit. The classic list alone is about 47k characters. The feature doc and audit prompt now use a batched loop of 200 files per batch.
  • The Broken Access Control and Swagger feature docs note the temporary trigger. The historical fix docs are left alone.

Follow-ups (not in this PR)

  • ESLint react/no-danger and react/jsx-no-script-url.
  • A shared sanitizeHttpUrl helper.
  • Optional justified suppressions on the four sanitized dangerouslySetInnerHTML sites.
  • Call-site tracking.
  • A CodeQL paths-ignore for vendored bundles, if needed.
  • Revert the temporary triggers when V2 merges into Development.

Fixes #1571
Found during #1543

scripts/check_xss_sinks.py now inspects .ts, .tsx, .jsx and .mjs files with
a small TypeScript/JSX lexer and eleven React-aware rules: raw HTML through
dangerouslySetInnerHTML or DOM HTML sinks, code evaluation, javascript: URLs,
dynamic JSX and DOM URL attributes, navigation sinks, raw-HTML markdown
options, and ports of the inline-handler, attribute-interpolation and
marked.parse checks. Declaration files and application/v2_ui/public/vendor/
are skipped. Output for existing .js, .html and .py files is unchanged apart
from the vendored V2 libraries, which are now skipped too.

A full-mode run over the 462 inspected V2 source files goes from 59 raw hits
to 28 after tuning, and every remaining hit has been reviewed as safe. The
per-finding reasons are in XSS_PR_GUARDRAILS_V2_BASELINE_TRIAGE.md.

xss-sink-check.yml now covers the new suffixes. It, broken-access-control-check,
swagger-route-check, python-syntax-check, malicious-pr-security-review and
codeql (push and pull_request) also run for paullizer-react-v2-ui. Each entry
is marked temporary until the V2 branch merges into Development, following
the feature/admin-settings-ia precedent. xss-full-scan.yml adds an on-demand
whole-file scan that mirrors the Broken Access Control full scan.

The XSS instruction applies to the new file types and gains a React/TypeScript
section. The audit prompt covers the V2 source and batches its file list,
because the whole-application list is longer than the Windows command-line
limit. The feature doc gains a V2 section, and the Broken Access Control and
Swagger docs note the temporary trigger.

The self-test gains eight TypeScript tests that raise explicitly, so they
still check under python -O.

Tooling only: no application change and no version bump.

Refs microsoft#1571
Refs microsoft#1543

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread functional_tests/test_xss_guardrails_checker.py Fixed
CodeQL flagged the bare `except OSError: pass` in the self-test's
`remove_tree` helper (py/empty-except, note) on microsoft#1576. The permission
reset is best effort: `shutil.rmtree(..., ignore_errors=True)` runs next,
so a file that stays read-only is simply left behind. The comment now says
so.

Refs microsoft#1571

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) marked this pull request as ready for review September 29, 2026 23:33
@paullizer
Paul Lizer (paullizer) merged commit 23587dd into microsoft:paullizer-react-v2-ui Sep 29, 2026
11 checks passed
Paul Lizer (paullizer) added a commit that referenced this pull request Sep 30, 2026
Brings #1578 (flow canvas stable re-renders, 0.261.205), #1579 and
#1576 (pytest return-False guard, XSS guardrail updates).

Conflicts: config.py keeps Phase 4's 0.261.207; release_notes.md puts
the v0.261.207 section above v0.261.205 and restores the unindented
v0.261.204 heading.

Refs #1547

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Paul Lizer (paullizer) added a commit to paullizer/simplechat that referenced this pull request Sep 30, 2026
Record 3b (microsoft#1577, v0.261.208) and Phase 4 (microsoft#1580, v0.261.207) as shipped
and mark 3c (the Ask AI tab) in progress. Add what later phases build on,
their follow-ups, the side fixes from microsoft#1576, microsoft#1578 and microsoft#1579, and gotcha 61
(Microsoft 365 pending-action run links). Docs only; no version bump.

Refs microsoft#1543

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants