CVE-2026-77088
justhtml 0.9.0 through 1.21.0 Cross-Site Scripting via code-span
justhtml versions 0.9.0 through 1.21.0 contain a cross-site scripting vulnerability in to_markdown() where inline code spans fail to account for blank lines as block boundaries. Attackers can inject blank lines into code or pre element text to break the inline span, causing sanitized HTML to be emitted unescaped and re-parsed as live Markdown by compliant renderers.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- None
- User interaction
- Passive
What an attacker can affect
- Confidentiality
- Low
- Integrity
- Low
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
5.3
justhtml 0.9.0 through 1.21.0 Cross-Site Scripting via code-span
justhtml versions 0.9.0 through 1.21.0 contain a cross-site scripting vulnerability in to_markdown() where inline code spans fail to account for blank lines as block boundaries. Attackers can inject blank lines into code or pre element text to break the inline span, causing sanitized HTML to be emitted unescaped and re-parsed as live Markdown by compliant renderers.
- Record
CVE-2026-77088- CVSS 4.0
- 5.3 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N - CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79
- Updated
- 2026-11-02
EUVDEUVD-2026-64657
5.3
justhtml versions 0.9.0 through 1.21.0 contain a cross-site scripting vulnerability in to_markdown() where inline code spans fail to account for blank lines as block boundaries. Attackers can inject blank lines into code or pre element text to break the inline span, causing sanitized HTML to be emitted unescaped and re-parsed as live Markdown by compliant renderers.
- Record
EUVD-2026-64657- CVSS 4.0
- 5.3 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-3r4x-v5v7-fm2r
5.3
justhtml versions 0.9.0 through 1.21.0 contain a cross-site scripting vulnerability in...
justhtml versions 0.9.0 through 1.21.0 contain a cross-site scripting vulnerability in to_markdown() where inline code spans fail to account for blank lines as block boundaries. Attackers can inject blank lines into code or pre element text to break the inline span, causing sanitized HTML to be emitted unescaped and re-parsed as live Markdown by compliant renderers.
- Record
GHSA-3r4x-v5v7-fm2r- CVSS 4.0
- 5.3 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X - CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79
- Updated
- 2026-11-02
GitHub AdvisoriesGHSA-jf6w-2mvx-633j
6.1
justhtml: to_markdown() code-span blank-line breakout enables XSS
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. …
- Record
GHSA-jf6w-2mvx-633j- CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79, CWE-116
- Updated
- 2026-11-02
NVD
5.3
justhtml versions 0.9.0 through 1.21.0 contain a cross-site scripting vulnerability in to_markdown() where inline code spans fail to account for blank lines as block boundaries. Attackers can inject blank lines into code or pre element text to break the inline span, causing sanitized HTML to be emitted unescaped and re-parsed as live Markdown by compliant renderers.
- Record
CVE-2026-77088- CVSS 4.0
- 5.3 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X - CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79
- Updated
- 2026-11-02
OSV
5.3
justhtml 0.9.0 through 1.21.0 Cross-Site Scripting via code-span
justhtml versions 0.9.0 through 1.21.0 contain a cross-site scripting vulnerability in to_markdown() where inline code spans fail to account for blank lines as block boundaries. Attackers can inject blank lines into code or pre element text to break the inline span, causing sanitized HTML to be emitted unescaped and re-parsed as live Markdown by compliant renderers.
- Record
CVE-2026-77088- CVSS 4.0
- 5.3 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N - Weakness
- CWE-79
- Updated
- 2026-11-02
OSVGHSA-jf6w-2mvx-633j
6.1
justhtml: to_markdown() code-span blank-line breakout enables XSS
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. …
- Record
GHSA-jf6w-2mvx-633j- CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-116, CWE-79
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
justhtmlPyPI |
0.9.0 to 1.21.0 | 1.22.0 |
justhtmlPyPI |
0.9.0 up to 1.22.0, plus listed versions 0.10.00.11.00.12.00.13.00.13.1 Show 45 moreShow less0.14.00.15.00.16.00.17.00.18.00.19.00.20.00.21.00.22.00.23.00.24.00.25.00.26.00.27.00.28.00.29.00.30.00.31.00.32.00.33.00.34.00.35.00.36.00.37.00.38.00.39.00.40.01.0.01.1.01.10.01.11.01.12.01.13.01.14.01.15.01.16.01.17.01.18.01.19.01.2.01.20.01.21.01.3.01.4.01.5.0 |
1.22.0 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-77088 is a medium vulnerability (CVSS 5.3) in Emilstenstrom Justhtml, published June 25, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.26%, the 18th percentile. Fixed in justhtml 1.22.0.
References
github.com/EmilStenstrom/justhtml/security/advisories/GHSA-jf6w-2mvx-633j
vulncheck.com/advisories/justhtml-through-cross-site-scripting-via-code-span
nvd.nist.gov/vuln/detail/CVE-2026-77088
github.com/advisories/GHSA-3r4x-v5v7-fm2r
github.com/advisories/GHSA-jf6w-2mvx-633j
github.com/CVEProject/cvelistV5/tree/main/cves/2026/77xxx/CVE-2026-77088.json
github.com/EmilStenstrom/justhtml
Left out here: 5 versions and the rest of 2 source descriptions. The JSON record has them.