Skip to content

fix(render): escape markdown output and allowlist URL schemes - #23

Merged
chaliy merged 1 commit into
mainfrom
claude/project-thread-ulbh3z
Oct 27, 2026
Merged

chaliy merged 1 commit into
mainfrom
claude/project-thread-ulbh3z

Conversation

@chaliy

@chaliy chaliy commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Михайло · project thread

What changed

Markdown output is now safe to render, for every input format. The PR is rebased onto current main, so it covers IR v2 (footnotes, definition lists, figures, task items, table spans), math (#34), transforms (#36), relative URL resolution (#31) and the limits from #27. #27's output and deadline checks are kept in the reworked renderer.

  • Text renders verbatim. These characters are escaped:

    • backtick, *, _ (at word boundaries), [, ], the less-than sign, ~, | and entity-like &;
    • line-start block markers: #, >, -, +, =, 1..

    Document text can no longer produce raw HTML, links, emphasis, headings, lists or tables.

  • URL scheme allowlist. Links keep http, https, mailto, tel, ftp and relative URLs. Images keep http, https, relative URLs and raster data:image/*. Any other scheme is dropped and its link text or alt text is kept: javascript:, vbscript:, data:text/html, file:. Scheme detection ignores whitespace and control characters and reads the whole scheme.

  • Destinations and titles. Parentheses, angle brackets, backslash and spaces are percent-encoded. Entity-like & is escaped, so javascript&colon; cannot decode into a scheme. Empty destinations are written <>. Titles escape quotes, backslash and less-than.

  • Code. Fences and spans are longer than any backtick run inside them, and adjacent spans cannot merge their fences. The code-block language keeps only its first word.

  • Math renders as code. TeX comes from page markup, and plain GFM has no math syntax, so $tex$ let an annotation containing an img tag or a link through. Math now uses GitHub's forms: $`tex`$ inline and a ```math fence for display math. GitHub renders both as math. Every other renderer shows them as inert code.

  • Structure stays in its container.

    • Block nodes inside inline content are flattened to one line. Examples: a code block or display math in a table cell, a paragraph in a heading.
    • List item bodies are indented by the marker width, so content nested under 1. items now nests correctly.
    • Every block starts on its own line, and hard breaks never produce a blank line.
    • Nested links render as text.
  • Raw text in the IR. The DOCX and spreadsheet parsers stop pre-escaping table pipes, so the JSON IR holds raw text. The renderer escapes pipes in table context.

All escaping lives in the new render/escape.rs, with the design decisions in its header. The new invariant is S17, since #27 took S14 to S16.

Why

Threat model T1 promised "no HTML pass-through", but the renderer escaped nothing. On main, a probe page showed four problems:

  • it produced a raw img tag with an onerror handler;
  • it kept javascript: links;
  • a triple-backtick line inside a pre block closed the fence;
  • links broke on ).

A fuzzing pass against the new oracle then found eight more ways content could escape its container. The cargo-fuzz harness comes in a follow-up PR. Each finding is a regression test here.

Before / After

Input (HTML) is a paragraph containing # not heading *x*, followed by:

  • an escaped img tag with an onerror handler,
  • a link to javascript:alert(1),
  • a link to http://x/a)b,
  • a pre/code block holding a triple-backtick line, then evil, then another triple-backtick line.

Before:

  • the paragraph rendered as a heading with emphasis and a live img tag;
  • the script link was kept;
  • the second URL was split at );
  • the inner backticks closed the fence, so evil ended up outside the code block.

After:

\# not heading \*x\* \[img tag with backslash-escaped less-than, shown as text]

click[p](http://x/a%29b)

````
```
evil
```
````

The new tests/output_safety.rs has 39 tests, 4 of them for math. 22 of them failed on the old main. A pulldown-cmark (GFM) oracle checks four things:

  • no raw HTML,
  • no unsafe destination,
  • the expected block structure,
  • verbatim text.

Proptests cover arbitrary text, URLs and code. #27's limits tests pass in debug and release, including the deep-nesting tests under a small stack.

just pre-pr passes locally except cargo deny, which is not installed here; the CI Audit job runs it. New dev-dependency: pulldown-cmark (MIT), used in tests only.

Risk

  • Medium.
  • Output changes:
    • Text with Markdown-significant characters is now backslash-escaped.
    • Links and images with schemes outside the allowlist are dropped.
    • Empty link targets are now written <>.
    • Content nested under ordered list items is now indented 3 or more spaces.
    • Math now renders as $`tex`$ and ```math.
  • Tests updated to match:

Checklist

  • Tests added or updated
  • Backward compatibility considered (CHANGELOG Unreleased entry)

🤖 Generated with Claude Code

@chaliy chaliy self-assigned this Oct 26, 2026
@chaliy
chaliy force-pushed the claude/project-thread-ulbh3z branch 2 times, most recently from 4162ad7 to 26258a7 Compare October 27, 2026 01:04
Document text could emit raw HTML, links, emphasis, headings and code-fence
breakouts, and javascript: links passed through unchanged (threat model T1).

- New render/escape.rs: text escaping (inline syntax, line-start block
  markers), code fences/spans longer than inner backtick runs, link and image
  scheme allowlists, percent-encoded destinations, escaped titles
- Structure cannot leave its container: blocks in inline context flattened,
  list bodies indented by marker width, blocks always start a new line,
  hard breaks never make blank lines, nested links render as text
- DOCX and spreadsheet parsers no longer pre-escape table pipes; the IR
  holds raw text and the renderer escapes in table context
- output_safety tests with a pulldown-cmark oracle and proptests (S14);
  cases found by a fuzzing pass against that oracle are regression tests
@chaliy
chaliy force-pushed the claude/project-thread-ulbh3z branch from 26258a7 to a8152a0 Compare October 27, 2026 01:22
@chaliy
chaliy merged commit 02bad4b into main Oct 27, 2026
13 checks passed
chaliy added a commit that referenced this pull request Oct 27, 2026
Re-run after #23 (markdown escaping): product_page now fails
contains "4.6 out of 5 stars" because the renderer emits 4\.6, which is
not a list marker. Recorded as an open gap.
chaliy added a commit that referenced this pull request Oct 27, 2026
<!-- claude-projects-attribution: {"github_login":"chaliy"} -->
_Requested by **Михайло** · [project
thread](https://claude.ai/code/project/chan_01MUtksmx61ypcr6wBFEWsbM?thread=cmsg_01MUtksmx61ypcr6wBFEWsbMDLeuybnBNqgVL9B9kMmARU)_

## What changed
Text that only looks like a block marker at the start of a line is no
longer backslash-escaped. `#`, `+` and ordered markers (`1.`, `1)`) are
escaped only when followed by whitespace or the end of the text run
(where the next node could supply the space). `>`, `-` and `=` stay
always escaped (quotes, thematic breaks, setext underlines).

## Why
Regression from #23 found by the eval: `4.6 out of 5 stars` rendered as
`4\.6 out of 5 stars`. The backslash is noise for text consumers, and
`4.6` can never open a list.

## Before / After
| Input text | Before | After |
|---|---|---|
| `4.6 out of 5 stars` | `4\.6 out of 5 stars` | `4.6 out of 5 stars` |
| `#hashtag` | `\#hashtag` | `#hashtag` |
| `+1 for this` | `\+1 for this` | `+1 for this` |
| `1. li` | `1\. li` | `1\. li` (unchanged) |
| `#` then node ` h` | `\# h` | `\# h` (unchanged) |

New tests in `tests/output_safety.rs`:
`line_start_marker_lookalikes_render_verbatim` (failed before the fix)
and `line_start_marker_at_run_end_stays_escaped`. The pulldown-cmark
oracle still sees exactly one paragraph for every case. `cargo test
--workspace` passes.

## Risk
- Low
- Narrows escaping only where CommonMark cannot open a block; run-end
case stays escaped so split text nodes cannot combine into a marker.

## Checklist
- [x] Tests added or updated
- [x] Backward compatibility considered (output-only change; CHANGELOG
Fixed, threat model T1 and eval gap note updated)

---
_Generated by [Claude
Code](https://claude.ai/code/session_01FWdY28wwTahFw3u9xhDBAv)_
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