Repository navigation
🔒 FIX: escape user content in renderers and harden attrs default - #150
Open
chrisjsewell wants to merge 13 commits into
Open
chrisjsewell wants to merge 13 commits into
chrisjsewell wants to merge 13 commits into
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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"><xss>` 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 `&` (`class="block-a&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 & 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 `&` now displays as the literal text `&`. 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
masterbefore and after, and covered by a regression test.Advisories addressed
texmathdollarmath/amsmathdollarmathidattribute and permalinkhrefmyst_block(name)=in the rendered anchorattrs/attrs_blockallow arbitrary event handlers by defaultallowedlist is given, strip event-handler (on*) andstyleattributes by defaultNotes on the
attrschange (#5)This is the one behavioural change, so calling it out:
allowedlist,on*andstyleattributes are now removed by default (moved intotoken.meta["insecure_attrs"], same mechanism as the existing allow-list). Benign attributes (width,data-*,class, …) are unaffected.("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 explicitallowedlist. Documented in both docstrings and the changelog. Happy to switch to the stricter allow-list default if preferred.[text]{...}) assignedtoken.attrsdirectly and bypassed_add_attrsentirely — so it ignored theallowedallow-list too, not just the new denylist. That path (the advisory's own PoC,[click me]{onclick=...}) now routes through the filter, so an explicitallowedlist finally applies to spans as well.Tests & fixtures
test_texmath,test_dollarmath,test_myst_block,test_attrs).texmath_dollar.md,texmath_bracket.md,myst_block.md) that previously encoded the unescaped output; only the expected blocks changed, inputs untouched. Notablytexmathnow matches whatdollarmathalready did for the same$1+1<3$input.ruff,ruff format, andmypy --strictall clean.