Skip to content

🔒 FIX: escape user content in renderers and harden attrs default - #150

Open
chrisjsewell wants to merge 13 commits into
masterfrom
claude/practical-goldberg-xaa9j6
Open

chrisjsewell wants to merge 13 commits into
masterfrom
claude/practical-goldberg-xaa9j6

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Jul 21, 2026 •

Copy link
Copy Markdown
Member

Addresses the XSS advisories reported against default configurations in #143 (thanks to @KrisKennawayDD / Datadog for the detailed write-ups).

Each fix was reproduced against current master before and after, and covered by a regression test.

Advisories addressed

# Advisory Fix
1 XSS via unescaped math content in texmath HTML-escape math content (and the equation-number label) in the default renderer, consistent with dollarmath/amsmath
2 XSS via unescaped equation label in dollarmath Escape the label in the generated id attribute and permalink href
3 XSS via unescaped target label in myst_block Escape the target label (name)= in the rendered anchor
5 attrs/attrs_block allow arbitrary event handlers by default When no allowed list is given, strip event-handler (on*) and style attributes by default

Advisory 4 (quadratic ReDoS in admon) is not included here — it's already handled in #148.

Notes on the attrs change (#5)

This is the one behavioural change, so calling it out:

  • With no explicit allowed list, on* and style attributes are now removed by default (moved into token.meta["insecure_attrs"], same mechanism as the existing allow-list). Benign attributes (width, data-*, class, …) are unaffected.
  • I chose a denylist baseline rather than a secure-by-default allow-list (("id", "class")) to avoid breaking the plugin's core "add arbitrary attributes" feature for the common trusted-content case. This is a baseline protection, not a full sanitiser — it won't stop e.g. href="javascript:"; untrusted input should still pass an explicit allowed list. Documented in both docstrings and the changelog. Happy to switch to the stricter allow-list default if preferred.
  • While fixing this I found that the span path ([text]{...}) assigned token.attrs directly and bypassed _add_attrs entirely — so it ignored the allowed allow-list too, not just the new denylist. That path (the advisory's own PoC, [click me]{onclick=...}) now routes through the filter, so an explicit allowed list finally applies to spans as well.

Tests & fixtures

  • Added regression tests for all four fixes (test_texmath, test_dollarmath, test_myst_block, test_attrs).
  • Regenerated three fixtures (texmath_dollar.md, texmath_bracket.md, myst_block.md) that previously encoded the unescaped output; only the expected blocks changed, inputs untouched. Notably texmath now matches what dollarmath already did for the same $1+1<3$ input.
  • Full suite passes (525), ruff, ruff format, and mypy --strict all clean.

claude and others added 2 commits July 21, 2026 14:18
Addresses the XSS advisories reported against default configurations in #143:

- texmath: HTML-escape math content and equation-number labels in the default
  renderer (consistent with dollarmath/amsmath). Input such as
  `$<img src=x onerror=alert(1)>$` previously emitted live markup.
- dollarmath: escape equation labels in the generated `id` attribute and the
  permalink `href`.
- myst_block: escape target labels `(name)=` in the rendered anchor.
- attrs/attrs_block: when no `allowed` list is given, strip event-handler
  (`on*`) and `style` attributes by default (kept in meta `insecure_attrs`).
  Text spans `[text]{...}` now also honour the `allowed` list, which they
  previously bypassed. This is a baseline protection, not a full sanitiser.

The admonition ReDoS (advisory 4) is addressed separately in #148.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DS3iVi5q1TepHaMymmmeJy
Resolve the XSS advisories from #143 where plugins interpolated
attacker-controlled markdown into their default HTML render rules without
escaping:

- texmath: escape the math source in the default render() stub and the
  math_block_eqno equation number, across every delimiter flavor
- dollarmath: escape the equation label in the id/href attributes (the
  content was already escaped)
- myst_block: escape the target label in the <a> body and href

Harden attrs/attrs_block: with no explicit allowed list, divert event-handler
(on*) and style attributes to token.meta["insecure_attrs"] instead of emitting
them, so the default configuration cannot inject script. The inline span path
previously set token.attrs directly and bypassed the filter; route it through
_add_attrs with the allowed set threaded in. Other attribute names are
unchanged.

Update the texmath/myst fixtures that encoded the old unescaped output, and add
a categorical guard (tests/test_xss.py) that renders injection probes through
each plugin to catch the next unescaped interpolation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4mwfRr7ZkjiwPATm6Q4ue
@codecov

codecov Bot commented Jul 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.38%. Comparing base (d11bdaf) to head (03f3cfc).
⚠️ Report is 20 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #150      +/-   ##
==========================================
+ Coverage   92.80%   93.38%   +0.58%     
==========================================
  Files          31       40       +9     
  Lines        1835     2299     +464     
==========================================
+ Hits         1703     2147     +444     
- Misses        132      152      +20     
Flag Coverage Δ
pytests 93.38% <100.00%> (+0.58%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

chrisjsewell added a commit that referenced this pull request Nov 9, 2026
## What and why

Two more HTML-injection points in the #143 family, both reachable with
`html=False`:

- **colon_fence** (default renderer): the block name was interpolated
into `class="block-..."` unescaped,
  after entities in the info string had been unescaped.
`:::a"onmouseover="alert(1)` rendered
`class="block-a"onmouseover="alert(1)"`,
  and `:::a&quot;&gt;&lt;xss&gt;` rendered a raw `<xss>` element.
  The name is now passed through `escapeHtml`.
- **tasklists** with `label=True, label_after=True`: the label is built
from the item's source text
and emitted as an `html_inline` token, so `- [ ] <img src=x
onerror=alert(1)>` rendered a raw `<img>`.
  The label text is now passed through `escapeHtml`.

## How it was tested

- New regression tests, both with `html=False` and exact expected
output:
`tests/test_colon_fence.py::test_block_name_is_escaped` (raw-quote and
entity-encoded payloads, plus a benign `&`)
and `tests/test_tasklists.py::test_label_after_is_escaped` (raw HTML,
`&`, plain text),
with `test_label_after_entity_is_shown_literally` pinning how a source
entity appears in the label
(it asserts the label only, as earlier inline children of a
`label_after` item are also rendered outside it,
  an existing quirk not changed here).
- Every case except the plain-text one fails on master and passes with
its fix;
  reverting either source file at HEAD makes its tests fail again.
- `pytest -q`: 518 passed (511 on master plus the 7 new cases),
markdown-it-py 4.2.0, Python 3.12.
- `pre-commit run --all-files` (ruff, ruff-format, mypy): clean.
- No fixture file changed; all existing colon_fence and tasklists
fixtures render byte-identical.

Part of #143

This does not close #143. What remains of it is in other open PRs:
the texmath, dollarmath and myst_block escaping and the `attrs` default
(#150),
the ReDoS fixes (#148),
and a test that guards every plugin renderer against XSS (#151).

## Notes for the reviewer

- Output changes for benign input:
a colon_fence block name containing `&` now emits `&amp;`
(`class="block-a&amp;b"` instead of `class="block-a&b"`),
  which is the same attribute value;
and in tasklists `label_after` mode, label text containing `&`, `<`, `>`
or `"`
  is now escaped (`a & b` becomes `a &amp; b`, which is correct HTML).
The label is the item's raw source text (it already showed `*emphasis*`
literally),
so a source entity such as `&amp;` now displays as the literal text
`&amp;`.
This applies with `html=True` as well; on master, inline HTML in a
`label_after` item already produced broken markup.
  No existing fixture uses `label_after`.
- Adding these two plugins to `tests/test_xss.py` (which arrives with
#151) is left for after those PRs land.
`style` cannot run script, and MyST-Parser reads `{style=lower-alpha}`
on an ordered list to choose its numbering, so stripping it by default
broke existing documents. The default baseline now removes only
event-handler (`on*`) attributes.
`[a](https://x){href="javascript:alert(1)"}` rendered a live
`javascript:` link even with `html=False`, bypassing the check
markdown-it applies to `[a](javascript:...)` itself.

`href`, `src`, `action`, `formaction` and `xlink:href` (any case) are now
removed, into `token.meta["insecure_attrs"]`, when `md.validateLink`
rejects their normalised value, whatever `allowed` says. Tabs, newlines
and surrounding control characters, which browsers ignore when reading
the scheme, are removed before the check.
The payload `("><svg/onload=alert(1)>)` contains `(1)`, which ends the
label parse, so the input rendered as a paragraph and both tests passed
without the fix. The new payload has no parentheses; the test asserts it
parses as `math_block_label` and checks the exact escaped output, for the
default and an identity `label_normalizer`, with `html=False`.
- Build markdown-it with `html=False`: the `commonmark` preset allows raw
  HTML, so the docstring's claim that it is disabled was wrong.
- Each case states text the output must contain, so a payload the plugin
  does not parse cannot pass without testing anything.
- Add quote-only attribute breakout probes (`a" onxss="1`) for the
  dollarmath label, the myst target and the colon_fence class, which the
  `"><xss>` probe alone does not catch.
- Add colon_fence block name and tasklists `label_after` cases (#165),
  and a `javascript:` URL in an attrs `href`.
- The texmath equation number case passed without its fix, because
  texmath never renders equation numbers (its render rules close over the
  loop variable, B023). It is now its own strict xfail, so fixing that
  bug turns it red and the marker is removed.
- `style` is kept by the attrs default, so its case is gone.
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.

3 participants