GHSA-jf6w-2mvx-633j: justhtml: to_markdown() code-span blank-line breakout enables XSS
justhtml: to_markdown() code-span blank-line breakout enables XSSSummaryIn `justhtml` 0.9.0 through 1.21.0, `to_markdown()` renders `<code>` text (and `<pre>` text inside a link) as an inline Markdown code span whose only protection is backtick-fence length. A blank line (`\n\n`) in that text terminates the inline span in any compliant Markdown renderer, so attacker-controlled text that survived HTML sanitization is emitted **unescaped** after the blank line and is re-parsed as live raw HTML/Markdown — yielding XSS in the default configuration. Likely **CWE-79 (Cross-site Scripting)** arising from **CWE-116 (Improper Encoding/Escaping of Output)**.Details`to_markdown()` is documented as a safety surface. `docs/text.md` states the guarantee applies "to the HTML produced by rendering that Markdown with a compliant Markdown renderer," and `SECURITY.md` promises `to_markdown()` "escapes line-start Markdown markers that could change block structure" and "uses code fences long enough to contain backticks safely."The inline code-span helper only sizes the backtick fence; it never accounts for block boundaries:`src/justhtml/node.py:32-41` (tag `v1.21.0`):The element's text is taken verbatim (`strip=False`, so embedded newlines are preserved) and routed into that helper:`src/justhtml/node.py:1061-1078` (tag `v1.21.0`):A Markdown **inline code span is an inline construct and cannot span a block boundary**: a blank line ends the paragraph, the opening backticks are left unmatched (literal), and everything after the blank line is parsed as ordinary Markdown — independent of fence length. Because CommonMark passes raw inline HTML through by default, text such as `<img src=x onerror=...>` becomes a live element.Reachability with default settings: `JustHTML(html)` sanitizes by default; `<code>` and `<pre>` are in `DEFAULT_POLICY.allowed_tags`; default sanitization preserves their text and the blank line (whitespace collapsing is opt-in). The payload lives in **text**, not a URL attribute, so URL-scheme sanitization never applies. The tokenizer decodes character references in normal text before DOM insertion, so `&lt;img …&gt;` enters the DOM as literal `<img …>` text while passing HTML sanitization.Two in-repo asymmetries confirm this is an unguarded path rather than intended behavior:**Plain text-node content is HTML-escaped** before Markdown escaping, so the same `&lt;img …&gt;` outside a code span is neutralized to `&lt;img …>`. Inside a code span it is not escaped — the fence is assumed sufficient.**`<pre>` outside a link uses a block fence** (`minimum=3`, line 1066), which a  blank line cannot break. The same `<pre>` **inside a link** (line 1064) and all `<code>` use the inline span, which a blank line breaks.PoCSelf-contained, runs entirely in Docker against the pinned PyPI release. Static by default: the rendered HTML is **parsed** to show a live handler-bearing element materializes; no JavaScript is executed on the default path.`Dockerfile`:`poc.py`:Build and run:Observed output (`justhtml 1.21.0`, `markdown-it-py 4.2.0`):The exploit is byte-identical to the inert control plus a single blank line
(`\n\n`). Deterministic: same input → same result.Optional execution confirmation (`docker run --rm justhtml-md-poc python3 /poc/poc.py --prove-exec`)
— supplementary; the parse proof above is canonical. Inert marker only:ImpactThis is a cross-site scripting vulnerability (CWE-79). It affects any application that follows the documented pipeline: sanitize untrusted HTML with `JustHTML(...)` under default settings, call `to_markdown()`, and render the result with a CommonMark-compliant renderer (raw-HTML passthrough is the CommonMark default).An attacker only needs to control HTML text inside a `<code>` element, or a `<pre>` element within a link — no custom policy and no `sanitize=False`. Any user who then views the rendered page executes attacker-controlled script in their own origin, enabling cookie/session theft or actions performed as the victim.Severity: CVSS 3.1 **6.1 (Moderate)**,
`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N`. Scope is Changed: the injected script runs in the origin of the page that renders the Markdown, a different security authority than the library that produced it.Recommended fixDo not represent text containing a block boundary as an inline code span. In `_markdown_code_span` / the `<code>` and in-link `<pre>` dispatch (`src/justhtml/node.py:1061-1078`), if the content contains a blank line (or any `\n`), emit it as a fenced code **block** — reusing the existing block path at lines 1066-1074, whose fence is not broken by blank lines — or collapse newlines in inline-code content. As defense-in-depth, escape HTML/Markdown-significant characters in code-span bodies rather than relying on fence length alone, matching the existing text-node escaping already applied elsewhere.ResourcesCWE-79 — https://cwe.mitre.org/data/definitions/79.htmlCWE-116 — https://cwe.mitre.org/data/definitions/116.htmlAffected source (tag `v1.21.0`): `src/justhtml/node.py:32-41` (`_markdown_code_span`),  `src/justhtml/node.py:1061-1078` (`<pre>`/`<code>` dispatch).CommonMark spec — code spans are inline and cannot contain a blank line; raw HTML is passed through by default: https://spec.commonmark.org/0.31.2/#code-spansNovelty: same vulnerability class as two prior, already-fixed `to_markdown()` advisories but a **distinct, still-unfixed variant**. The earlier fixes address
(a) HTML-escaping of plain text nodes and (b) backtick-fence **length** for `<pre>` code **blocks**. Neither addresses a **blank-line** break of an **inline**  code span: fence length is irrelevant to a block-boundary break, and code-span bodies are not HTML-escaped. The cited dispatch and helper are unchanged at `v1.21.0`, and `origin/main == v1.21.0` (no embargoed fix).
CVSS v3.1: 6.1