Security and limits
Trojan-Source stripping, scheme probes, attribute hardening and the nesting cap.
Generated from resources/examples/edge-cases.md, resources/examples/extensions.md and resources/examples/core.md - edit the cases there, not here. Each case links the conformance fixture it produces.
Security hardening
11 conformance fixtures
Carve is safe by default: when it emits HTML for untrusted input, dangerous URL schemes, event-handler attributes, and script-bearing CSS are neutralized before serialization. These pairs pin that behavior (normative: grammar PART 9 §25). The HTML renderer is the primary untrusted-output path; the rules below are always on and identical across implementations.
A javascript: link destination is rejected, leaving an empty href (the link text is preserved):
[click here](javascript:stealCookies)<p><a href="">click here</a></p>An autolink with a dangerous scheme is blanked the same way:
<vbscript:msgbox><p><a href="">vbscript:msgbox</a></p>The denylist also covers OS protocol-handler and command-execution schemes (CVE-2026-20841 class). These route to an operating-system handler that can launch a binary or open a macro-bearing document. A Windows document handler such as ms-office: is blanked, even when it embeds an inner URL:
[a](ms-office:ofe|u|http://evil/x.docm)<p><a href="">a</a></p>The Follina-class ms-msdt: handler is blanked:
[b](ms-msdt:/id)<p><a href="">b</a></p>The shell: scheme (and an ms-msdt: autolink) are blanked the same way:
[c](shell:Startup)
<ms-msdt:/id><p><a href="">c</a></p>
<p><a href="">ms-msdt:/id</a></p>Ordinary web and contact schemes remain allowed -- only the dangerous classes are neutralized. An https: link and a tel: link are kept intact:
[d](https://ok.com)
[e](tel:+15551234)<p><a href="https://ok.com">d</a></p>
<p><a href="tel:+15551234">e</a></p>An image whose source uses a dangerous scheme keeps its alt but drops the src value:
<img src="" alt="logo">An event-handler attribute (any on* name) is dropped entirely:
A [danger]{onclick="steal()"} span.<p>A <span>danger</span> span.</p>A style value containing a CSS expression( (or url(, @import, behavior:, -moz-binding) is blanked, keeping the harmless style slot:
A [danger]{style="x:expression(steal())"} span.<p>A <span style="">danger</span> span.</p>The srcdoc and formaction attribute names are dropped:
A [danger]{srcdoc="<script>"} span.<p>A <span>danger</span> span.</p>An attribute-block href/src override cannot reintroduce a dangerous scheme; the safe destination is kept and the override is ignored:
[safe](https://example.com){href="javascript:steal"}<p><a href="https://example.com">safe</a></p>Trojan-Source: heading ids are NFC-normalized and strip invisible controls
3 conformance fixtures
A heading id is NFC-normalized and stripped of bidi-override / isolate controls and zero-width characters (PART 9 §26), so visually identical source cannot produce diverging ids and an invisible control cannot smuggle a different target.
A precomposed é (U+00E9) yields id Café:
# Café<section id="Café">
<h1>Café</h1>
</section>A decomposed e + U+0301 yields the SAME id Café (NFC), while the rendered heading text keeps the author's decomposed sequence:
# Café<section id="Café">
<h1>Café</h1>
</section>A heading containing U+202E and U+200B yields an id with NEITHER (ABC); the rendered text drops the bidi-override but keeps the zero-width space:
# ABC<section id="ABC">
<h1>ABC</h1>
</section>Trojan-Source: rendered text and code strip bidi-override controls
2 conformance fixtures
A bidi-override / isolate control in rendered text or in a code span is dropped (PART 9 §26): it is DOM-inert, and entity-encoding it would let it decode back to the raw control downstream, so it is removed rather than escaped.
In paragraph text the control is stripped:
ab<p>ab</p>In a code span the control is stripped too (not entity-encoded):
`ab`<p><code>ab</code></p>Scheme probe strips Unicode whitespace
1 conformance fixture
The URL scheme probe strips ALL Unicode whitespace -- including NARROW NO-BREAK SPACE (U+202F) -- before matching the scheme (PART 9 §25), so an obfuscated javascript: destination cannot slip past the denylist.
A reference destination prefixed by U+202F then javascript: is rejected, leaving an empty href:
[click][a]
[a]: javascript:alert(1)<p><a href="">click</a></p>URL-list attributes are probed token-wise
12 conformance fixtures
342-url-list-attributes-are-probed-token-wise342-url-list-attributes-are-probed-token-wise-2342-url-list-attributes-are-probed-token-wise-3342-url-list-attributes-are-probed-token-wise-4342-url-list-attributes-are-probed-token-wise-5342-url-list-attributes-are-probed-token-wise-6342-url-list-attributes-are-probed-token-wise-7342-url-list-attributes-are-probed-token-wise-8342-url-list-attributes-are-probed-token-wise-9342-url-list-attributes-are-probed-token-wise-10342-url-list-attributes-are-probed-token-wise-11342-url-list-attributes-are-probed-token-wise-12
srcset, imagesrcset and ping carry a LIST of URLs rather than one, so the scheme probe runs on every token of the value as well as on its head, and any hit blanks the WHOLE value (PART 9 §25). The point is that a dangerous scheme gets the SAME answer wherever in the list it sits: reading position one and vouching for the rest is not a defense, it is a coincidence.
A javascript: candidate in the FIRST srcset position blanks the attribute:
{srcset="javascript:alert(1) 1x, safe.png 2x"}<img src="safe.png" alt="a" srcset="">The same two candidates in the other order blank it identically. This is the pair the rule exists for -- before it, the second spelling rendered verbatim in carve-js, carve-php and carve-rs alike (carve#1320):
{srcset="safe.png 1x, javascript:alert(1) 2x"}<img src="safe.png" alt="a" srcset="">imagesrcset has the same candidate-list grammar and gets the same treatment, so it is pinned rather than left to follow by analogy:
{imagesrcset="safe.png 1x, javascript:alert(1) 2x"}<img src="safe.png" alt="a" imagesrcset="">A candidate needs no space after the comma to be a candidate, so the comma has to count as a separator in srcset -- a whitespace-only split would read 1x,javascript:alert(1) as one descriptor and miss it:
{srcset="safe.png 1x,javascript:alert(1) 2x"}<img src="safe.png" alt="a" srcset="">ping is a space-separated list of URLs the user agent POSTs to on activation, and a non-leading token blanks it just the same:
[y](safe.html){ping="safe.html javascript:alert(1)"}<p><a href="safe.html" ping="">y</a></p>attributionsrc is the fourth member and the one that is not in the HTML Standard's own attribute index. It comes from the Attribution Reporting API, browsers ship it, and it sends a request to every URL in the list, so the criterion reaches it wherever it was specified:
[y](safe.html){attributionsrc="https://example.com/s javascript:alert(1)"}<p><a href="safe.html" attributionsrc="">y</a></p>THE SEPARATORS ARE PER ATTRIBUTE, and this pair is where that shows. ping's grammar holds no comma, so a lone URL carrying one in its path is one token and survives:
[y](safe.html){ping="https://example.com/a,data:x"}<p><a href="safe.html" ping="https://example.com/a,data:x">y</a></p>The same URL in srcset is blanked, because there a comma really does end a candidate and the split cannot tell this one from a boundary. That over-blanks a URL nobody would write, and reading it exactly would mean asking three engines for the HTML candidate-list algorithm byte for byte; the shape is pinned so they cannot each answer it differently:
{srcset="https://example.com/a,data:x 1x"}<img src="safe.png" alt="a" srcset="">The name is matched case-insensitively, like the on prefix, and the element still carries the author's spelling. Matching the exact bytes would leave SRCSET unprobed:
{SRCSET="safe.png 1x, javascript:alert(1) 2x"}<img src="safe.png" alt="a" SRCSET="">PROSE ATTRIBUTES ARE NOT TOKENIZED, and this pair is the reason the rule names a closed set instead of testing every value for scheme-shaped tokens. title, alt and aria-label legitimately carry colons, and a blanket check would refuse ordinary text:
[z](safe.html){title="See: RFC 3986, http://example.com"}<p><a href="safe.html" title="See: RFC 3986, http://example.com">z</a></p>THE TOKEN PASS IS ADDITIVE, and this pair is what says so out loud. The four names keep the leading-scheme probe on the WHOLE value too, so a value is blanked when EITHER the whole value probes dangerous OR any token does. Every case above blanks under both readings, which is why this one exists: the value-wide probe strips the ASCII whitespace the SPLIT breaks on, so java script:alert(1) is two harmless tokens -- java carries no scheme and script is not denylisted -- and one denied value. An implementation that ran the token pass INSTEAD of the value-wide probe would deny less here than the leading rule denied before this rule existed, and every other document in this section would still pass:
[y](safe.html){ping="java script:alert(1)"}<p><a href="safe.html" ping="">y</a></p>The comma-separated half of the set is pinned separately, because its split is the one an implementation spells on its own: an additive value-wide probe wired into the ping branch and forgotten in the srcset branch is a reachable state, and it renders this verbatim:
{srcset="java script:alert(1) 1x, safe.png 2x"}<img src="safe.png" alt="a" srcset="">Openers past the nesting cap are one paragraph
1 conformance fixture
Past MAX_NESTING_DEPTH (200) an opener stops recursing and becomes literal paragraph text (PART 9 §25). Those lines are ORDINARY paragraph text, so they group by the ordinary paragraph rule: consecutive over-cap openers, and any text following them, form one paragraph, with no trailing newline before </p>.
This was unstated and the three engines each chose differently - one paragraph per opener, one paragraph for all of them with a trailing newline, and one without it (carve#494). Nothing measured it, because no corpus document reached the cap and every gate compares HTML over the corpus.
The case is large because it has to be: the cap is 200, so no shorter document reaches the path at all. It is marked no-render so the docs page does not try to display 200 nested containers. The container marker is five colons because the body holds four-colon openers, and markdown-it-container closes on the first matching run it sees.
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
:::: note
x<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<aside class="admonition note">
<p>:::: note
:::: note
:::: note
x</p>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>
</aside>:::
A format character before a scheme is not stripped, and is inert
1 conformance fixture
§25's scheme probe strips leading controls, every Unicode space and the BOM. It stops there: the WHATWG URL parser strips C0 controls and space and nothing else, so a destination starting with ZERO WIDTH SPACE fails to parse as a URL at all and resolves as a relative path. It stays in the document as the author wrote it.
[x](javascript:alert(1))<p><a href="javascript:alert(1)">x</a></p>Inline extensions
1 conformance fixture
Leftover attributes RIDE the outermost semantic element: a consumed name renames the span rather than wrapping it, so an id or class lands on the element the author wrote it on, and hardening applies there.
[*Ctrl*+C]{#copy .shortcut kbd data-key="copy" onclick="alert(1)"}
[x]{kbd onclick="alert(1)"}<p><kbd id="copy" class="shortcut" data-key="copy"><strong>Ctrl</strong>+C</kbd>
<kbd>x</kbd></p>Autolinks
1 conformance fixture
The autolink body is built from URL characters only; < and > are not URL characters, so a < inside the angle brackets cannot be part of the body. The whole run is not an autolink and renders as fully escaped literal text (grammar url_autolink / url_char).
<http://a.com/<script>><p><http://a.com/<script>></p>