Skip to content

Fix XSS from code spans in safe mode - #737

Merged
nicholasserra merged 4 commits into
trentm:masterfrom
LuisFigueroaG:fix/safe-mode-code-span-xss
Oct 13, 2026
Merged

nicholasserra merged 4 commits into
trentm:masterfrom
LuisFigueroaG:fix/safe-mode-code-span-xss

Conversation

@LuisFigueroaG

Copy link
Copy Markdown
Contributor

Fixes #726 (the second case; the &#058 one was fixed in #728)

In safe mode, code spans are hashed before the HTML is sanitized, so anything that looks like a tag inside backticks is left alone at that point. The span gamut parses the text again later and doesn't always pair the backticks the same way. In the report a tag spans two lines, so the pairing changes, and text that had been skipped as "code" comes out as raw HTML:

<x![](`<a
>`)><svg onload=alert(origin)![](`<a
>`)
<p>&lt;x<img src="code&gt;&lt;a
&gt;&lt;/code" alt="" />&gt;<svg onload=alert(origin)<img src="code&gt;&lt;a
&gt;&lt;/code" alt="" /></p>

The tables extra has the same problem, because it splits cells on | after the code spans were protected, so a backtick pair that crosses a cell boundary isn't a code span any more:

| a | b |
|---|---|
| `x | <svg onload=alert(origin)>` |

gives <td><svg onload=alert(origin)&gt;` on master.

What I changed: when the code spans are protected, every < inside them is replaced with a hash that unescapes to &lt;. Whatever the later passes decide that text is, it can't come out as a tag, and real code spans are still only escaped once (a<b still renders as <code>a&lt;b</code>). Two places read the raw code span text and would otherwise see the hash: header ids (header-ids) and pygments highlighting in fenced code blocks. Both now unescape it first, so their output doesn't change.

Tests:

  • tm-cases/xss_code_spans_issue726: the case from the report, plus code spans with < in a paragraph and in a header with header-ids, to check they're escaped once and the id is unchanged.
  • tm-cases/xss_code_spans_in_tables_issue726: the tables case above.
  • tm-cases/safe_mode_code_spans_in_fenced_code_with_lexer: inline code spans in a fenced block with a lexer, which go through the pygments path.
  • test.py -- -knownfailure: OK
  • test/test_redos.py: 10 tests, OK
  • pycodestyle lib/markdown2.py: 412 lines vs 413 on master (no new warnings)

Code spans are protected from HTML sanitization before the main
conversion, but the span gamut doesn't always pair backticks the same
way (a tag spanning multiple lines changes the pairing), so text that
was skipped as "code" could be output as raw HTML.

Replace any `<` in those protected spans with a hash that unescapes to
`&lt;`, so they're safe however they end up being parsed while real
code spans still only get escaped once. Header ids and pygments
highlighting unescape the hash first so their output doesn't change.

Fixes trentm#726
@nicholasserra

Copy link
Copy Markdown
Collaborator

LGTM thank you!

@nicholasserra
nicholasserra merged commit d030ca7 into trentm:master Oct 13, 2026
18 checks passed
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.

More XSS's in safe mode

2 participants