Skip to content

Render HTML links and harden the 0.5 renderers against injection - #10

Merged
Maxanox merged 23 commits into
format-0-5-0from
cnd-36-render-links-and-harden-renderers
Oct 13, 2026
Merged

Maxanox merged 23 commits into
format-0-5-0from
cnd-36-render-links-and-harden-renderers

Conversation

@Maxanox

@Maxanox Maxanox commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Renders hyperlinks in the HTML output and hardens the 0.5 renderers against hostile CND content.

Stacked on #9. This branch builds on cnd-27-render-cnd-0-5-0. Merge #9 first; this PR will then be retargeted to format-0-5-0.

Hyperlinks (CND-36)

  • HTML links. A link with a text span now wraps its text in <a class="cnd-link">. A link with no span goes on the marker line. A link is never lost: when it cannot render inline, it falls back to the marker line. The cases are an empty span, a span inside a code or math mark, a link nested in another, and an unsafe address.
  • Links never nest. When two links overlap, the outer one, or the first one, wins.
  • One URL rule for HTML href and src and for Markdown link and image destinations. Only http, https and mailto, or a URL with no scheme, are kept (images also accept data:image/). Anything else stays plain text. The scheme is read as leniently as any reader of the output does: a browser strips control characters, tabs and newlines; Markdown strips Unicode whitespace.

Hardening (CND-37)

  • Math blocks. In Markdown, a math block is a math fence holding LaTeX source, or its text form when the source is not LaTeX, followed by its number.
  • Cell spans must be at least 1. The schema gains two minimum constraints; content hashes are unchanged.
  • Front matter escapes every control character and every non-printable character.
  • HTML metadata. An extra key cannot shadow a meta element the converter writes itself.
  • text_only keeps superscripts and subscripts readable: 10^3, H\_2O.
  • Lists. A block after a nested list makes the list loose.
  • API. expand_headings is exported.
  • Cosmetics.

Injection fixes from review

The review probed the output with markdown-it and html.parser. Each of these is fixed and has a regression test:

  • Line breaks in a link marker. A line break could break out of the Markdown link marker and inject a link or an image.
  • Image placeholders. Their alt text was written raw.
  • Labels and cite supplements. These were also written raw. Every label now goes through one injective mapping, so footnote references still resolve to their definitions.
  • Tildes. A ~~ next to another tilde could open a code fence.

Results

  • 900 tests pass, against 561 on the base.
  • Goldens change in three places, each explained in the commits: one link anchor in HTML, and two math fences.
  • New test-only dependency: mdit-py-plugins, for the footnote parsing tests.
  • Follow-up: CND-38. Markdown renderers that ignore case could confuse two labels differing only in case.

Closes CND-36
Closes CND-37

🤖 Generated with Claude Code

https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS

Maxanox and others added 23 commits October 13, 2026 19:44
A links edge with a text_span now makes the text it spans an
<a class="cnd-link">, through the inline layer's edge_wrap, which the
HTML renderer enables for the links family only; refs, cites and
footnotes keep their marker line. A links edge without a span goes to
the marker line as a link to its href.

Every href goes through one check, cnd.core.urls.is_safe_url: http,
https, mailto or no scheme at all, read as a browser reads it (leading
controls stripped, every tab and newline removed). An unsafe href is
never a link: the spanned text stays plain and a span-less href is
written as text. The Markdown link marker uses the same check, escapes
its text, and escapes its destination so that it parses back to the
href that was checked (no entity or backslash turns into a scheme).

A Markdown HTML table renders its cells with links=False: the
converter already appends every link to the marker line.

Part of CND-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A math block rendered as bare text, so its source was read as prose:
"a * b_1" turned into emphasis. It is now a fenced block with the info
string "math", its fence longer than any backtick run inside, holding
the producer's raw source when there is one, else the text. A
numbering value follows on its own line in parentheses (a value that
already has them keeps them as they are). The text-only option renders
it the same way: a fence is not HTML.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
Cell.rowspan and Cell.colspan accepted 0 or a negative number, which
no table can lay out. Both are now constrained to >= 1; the schema
gains "minimum": 1 on the two fields and the spec table says so. The
default stays 1, so the hash vectors do not move.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
_yaml_scalar escaped the backslash, the quote, \n, \r and \t only. A
NUL or ESC in a title made the front matter invalid YAML, and NEL,
U+2028 or U+2029 broke the scalar across lines. Every other C0
control, DEL, NEL and the two Unicode separators are now written as
\uXXXX; PyYAML reads the front matter back to the original strings.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
The HTML renderer wrote any ImageNode.path as src, javascript: and
data:text/html included. A path now becomes src only when it passes
the shared URL check (http, https, mailto or no scheme) or is a
data:image/ URI (cnd.core.urls.is_safe_image_source); any other path
renders the missing-image span with the alt text.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
… writes

A doc.extra key such as "description" or "viewport" produced a second
<meta> with that name next to the one the converter writes from the
document. A key equal to a name the converter emits (author,
description, keywords, date, status, version, viewport; compared
without case or surrounding spaces) is now skipped, and the conversion
reports it in its warnings.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
Under the text-only option sup and sub dropped to plain text, so
"10<sup>3</sup>" read "103" and "H<sub>2</sub>O" was fine only by luck.
sup now renders as ^x over one character and ^(text) over several, sub
as _x and _(text); where free text is escaped the "_" is written "\_"
so it never opens emphasis (markdown-it reads "H\_2O" and "10^3" as
plain text). underline and highlight stay plain; the default mode and
HTML are unchanged. Spec section 7 states the rule.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
An item holding a nested list and then a paragraph rendered tight, so
the paragraph's line was a lazy continuation of the nested list's last
item. A nested list followed by any other block now counts as several
blocks: the Markdown list goes loose and the HTML item wraps its
paragraphs in <p>. A nested list at the end of an item still keeps the
list tight. Both renderers share the rule (holds_several_blocks).

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A caller that renders a container's or a footnote's blocks itself
needs expand_headings, or a nested heading's section is lost; it was
reachable only through the module. It is now in cnd.core.render's new
__all__ (with the two renderers and holds_several_blocks) and
re-exported from cnd.core and cnd.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…class

A blank or whitespace admonition kind gave the class "cnd-admonition-";
the kind class is now left out when the kind is empty. The renderer
module docstring's NodeRenderer line, the HtmlNodeRenderer links
paragraph and the HtmlConverter head bullet are rewrapped to the
width of the text around them.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A math fence is read as LaTeX, so the Typst source a producer kept in
raw displayed wrongly there. The fence now holds raw.value only when
raw.format is "latex" (any case), and the block's text otherwise.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
Two wrapped edges whose spans overlap gave an edge inside another,
which for links is an <a> inside an <a>. Of overlapping wrapped edges
the outer or first one now wins and the other is left out; an edge
edge_wrap declines does not block another. wrapped_edges() says which
edges a render actually wraps (not one left out for its span, an atom
or an overlap), so a caller can render the others elsewhere.

Part of CND-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
… marker line

The HTML converter left every link with a text_span out of the marker
line, so a link whose span did not render inline was lost: an empty or
clamped span, a span inside a code or math atom, a link dropped as
nested inside or across another, an unsafe href. HtmlNodeRenderer now
says which links it wraps (inline_links, the same wrapped_edges
predicate its render uses), and every other link goes to the marker
line; an unsafe href shows there as text. A renderer without
inline_links, or with links=False, gets every link in the marker line.

Part of CND-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
The HTML bullet now says that a spanned link is an inline cnd-link
anchor, that anchors never nest, and that every link not rendered
inline falls back to the marker line. The Markdown bullet says that a
link marker is escaped and URL-checked, and that a math block is a math
fence holding LaTeX source or the text. A URL rule bullet states the
safety rule both follow: http, https or mailto, or no scheme; anything
else stays plain text.

Part of CND-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…ink marker

The marker's text was escape_inline(href), which keeps CR and LF. An
href such as "a\n\n[x](ms-msdt:e)" passes the URL check (it has no
scheme) and its second line became a link, a heading, a quote or an
image of its own. Every line ending in the marker text is now a space,
in the link branch and in the plain-text branch (which also goes
through escape_block now); the destination already percent-encodes
them.

Part of CND-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
is_safe_url stripped only U+0000-U+0020 before reading the scheme, so
" javascript:alert(1)" read as "no scheme", hence safe, while a
Markdown parser strips Unicode whitespace from a destination and links
to javascript:. Every leading Unicode whitespace (NEL, U+2028, U+3000
and the rest) and a BOM are now stripped too: the scheme is read as the
most lenient reader reads it. Spec section 7 says so.

Part of CND-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
TestFrontMatterScalars held literal U+2028 and U+2029 (in CONTROLS and
in the round-trip test), which editors and diff tools show as line
breaks. They are now written "
" and "
".

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
Text decoded as Latin-1 instead of cp1252 carries C1 controls where its
quotes and dashes were, and YAML forbids them unescaped, as it forbids
U+FFFE, U+FFFF and lone surrogates. The whole U+0080-U+009F range, the
two noncharacters and lone surrogates are now written as \uXXXX;
PyYAML reads a cp1252-style title back unchanged.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
The marker line left out a link whenever its object had rendered
inline in any text of the block. A producer may share one LinkRef
between two texts, inline in one and inside a code atom in the other,
and the second was lost. Inline links are now keyed on (text_index,
id(link)), the position ResolvedMarker already carries.

Part of CND-36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
The Markdown image wrote any path as its destination: no scheme check,
"&" and "\" left as they were (so "javascript&colon;…" parsed back as
javascript:), and a line ending let the rest of the path start a block
of its own. The destination now goes through escape_destination; a
path that fails is_safe_image_source renders the [[image:id alt="…"]]
placeholder instead of ![…](…); a line ending in the alt becomes a
space, in the image and in the placeholder.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A "~~" next to a "~" (in the text, or a nested strike's own delimiter)
made a run of three tildes or more. Such a run does not delimit, and at
the start of a line it opens a code fence that swallows every block
after it. A strike whose delimiter would touch a tilde now falls back
to <s>, or to plain text under the text-only option.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
…nert

The [[image:id alt="…"]] placeholder wrote its alt raw, so an alt
holding "<img … onerror=…>" became live HTML and "x](ms-msdt:e)" a
link; since the unsafe-path fallback, every image with an unsafe path
took that route. The figure and table placeholders (proposal 0012)
and the heading numbering prefix had the same pattern.

Under escape, every quoted placeholder value now goes through
escape_inline after the placeholder's own quoting (attr_value, made
public and now also used for the image alt), and the heading
numbering prefix is escaped and kept on one line. Without escape the
placeholders stay verbatim, as all free text does. An image alt also
escapes a backtick and "<": a code span, an autolink or raw HTML binds
tighter than the image brackets and could reach into the destination.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
A label is free text, and the Markdown converter wrote it raw in the
[label], [^label] and [@Label] markers, the [^label]: definitions and
the bibliography's **label**: "x](ms-msdt:e)" made a link, raw HTML
ran, and a space or a "]" broke a GFM footnote. Every label now goes
through one function, markdown_label: letters, digits and "-._:" kept,
every other character (and "%") percent-encoded as UTF-8. The mapping
is injective (unquote undoes it), so a footnote marker and its
definition still match and two labels never meet; ordinary labels are
unchanged. Where inline syntax applies the mapped label is also
escaped, and a cite supplement is escaped and kept on one line.

The HTML converter already escapes labels in ids, fragment hrefs and
marker text; a test now holds it. mdit-py-plugins joins the dev group
for the footnote syntax.

Part of CND-37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XxXUiDn2QyzNTYnZpeVFVS
@Maxanox
Maxanox changed the base branch from cnd-27-render-cnd-0-5-0 to format-0-5-0 October 13, 2026 20:19
@Maxanox
Maxanox merged commit 82ba11f into format-0-5-0 Oct 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant