Skip to content

fix(render): harden both renderers against hostile model strings (#35, #29) - #37

Merged
jbeda merged 10 commits into
mainfrom
fix/render-escaping
Jul 27, 2026
Merged

fix(render): harden both renderers against hostile model strings (#35, #29)#37
jbeda merged 10 commits into
mainfrom
fix/render-escaping

Conversation

@jbeda

@jbeda jbeda commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Hardens both renderers before ADR-0010's vendoring slice makes their inputs a trust boundary. Three parts, one PR.

Fixes #35
Fixes #29

1. codeSpan at the unconstrained call sites (#35)

A backtick in a code-spanned field closed its span early and dropped the remainder into the document as live Markdown/HTML. codeSpan (added in #34, fences past backtick runs) now wraps every schema-unconstrained value: attribute names, enum value names, action names, and an action's actor.

The audit found one call site the issue did not listenumValue.name in the Enums table — and two of the four it did list are stale: attributes[].type and relationships[].role are not code-spanned on current main. Their raw-HTML hole is closed by part 3 instead. Fields with a schema pattern (entity/enum/glossary keys, subtypeOf, a relationship's target, an invariant id) are left alone; render runs full structural validation before rendering, so those patterns genuinely hold.

Also fixed alongside: a pipe in a name ended its table cell. New codeCell escapes it, which GFM recognizes inside a code span.

2. Mermaid sanitize (#29)

Confirmed against real mmdc 11, base vs. change:

role before after
%%{init:{'theme':'forest'}}%% parsed as a directive — restyled the whole diagram (fill:#cde498), swallowed its own label inert, default theme, full text in the label
<angle> parsed as a tag — vanished with no diagnostic renders as <angle>

Runs of 2+ % collapse to one (a directive can't be built from a single one; 50% full still reads). &, <, > become character references, in that order so an author-written & isn't fused with one this function introduced — Mermaid decodes them back, so the reader sees what the author typed.

testdata/hostile_role.golden.mmd deliberately pinned the pre-fix behaviour; its own comment said fixing #29 must replace it. This does.

3. Prose fields render HTML as text — via a real parser (ADR-0014)

definition, description, a role, a note, an invariant statement, a scenario step emitted raw HTML. They stay Markdown — models backtick their entity names throughout and lint's backticked-term check depends on it — but a tag now renders as the text the author typed.

This part was rewritten twice under review, and the shape it landed on matters:

goldmark decides what is a tag. A hand-rolled scanner shipped two HIGH bugs past CI (a backslash-escaped backtick opened a fake span; a span was allowed to cross a paragraph break). Both are cases where a character scanner and a Markdown parser disagree, so the scanner is gone. internal/render/markdown now parses the prose with goldmark and escapes the byte ranges of ast.RawHTML and ast.HTMLBlock nodes, leaving every other byte unchanged.

It escapes what a parser calls HTML, not what it fails to call safe. The first goldmark attempt inverted this — it collected "literal" ranges and escaped the complement — which escaped the document's structure, and escaping structure changes the parse the offsets came from. That flattened blockquotes (turning a safe code block into a live tag), broke angle-bracket link destinations, and mangled autolinks. The allowlist has none of those failure modes.

goldmark's renderer is never run: only goldmark/ast, goldmark/parser and goldmark/text are imported, and the parser is built with parser.NewParser rather than goldmark.New, so the HTML renderer never enters the binary. Verified with go tool nm0 renderer, html or extension symbols.

On &: an author-written & passes through unchanged, including when it introduces a character reference. So an author who writes &lt; sees < on the page, not &lt;. That is a deliberate fidelity loss that follows from "leave every other byte unchanged"; a character reference cannot produce markup, so nothing unsafe follows from it. The Mermaid path still escapes every & — different consumer, different encoding. ADR-0014 records both.

Dependency

goldmark v1.8.4 — modelith's first real parsing dependency. Pure Go, no cgo, +617 KiB. Adds only net/url and net/netip from the net tree, both already allowed by TestADR_0011_OfflinePackages, which passes. .goreleaser.yaml, the SBOM step and action.yml need no changes.

One local workaround: goldmark panics with index out of range [-1] on certain unnormalised prose. Upstream #556 is the same bug and v1.8.4 already carries its fix, but that fix does not work — it swapped BlockIndent() for BlockOffset(), both of which return -1, comparing indent columns against bytes. Worked around by appending the trailing newline a document is defined to have, which moves no offset. To be reported upstream.

Golden churn

None in any committed .md. No prose field in examples/ or docs/05-parking-garage/ holds <, > or &, and no affected identifier holds a backtick. The only goldens that changed are the Mermaid testdata file, deliberately, and ADR-0014 itself.

Knowingly not fixed

Decided on cost against threat, not overlooked. Reaching any of these needs a model author who is hostile and already has commit access to the repository being rendered — who could edit the generated .md directly instead. That changes when ADR-0010's vendoring slice lands; the plan there is to warn on deps import/deps update that vendoring carries an injection risk and that models should only be vendored from sources you trust.

  • The parser configuration is CommonMark; every reader's is GFM. GFM's table transformer can dissolve a code span. Closing it means vendoring goldmark's table transformer into a renderer-free package — every extension file imports the root package, whose eager global drags in the HTML renderer (27 symbols vs 0 today). Rejected in ADR-0014 under Considered and rejected.
  • Link schemes. [click](javascript:alert(1)) passes through — a destination is Markdown, not raw HTML. Its own decision, not taken here.
  • MDX. map<string, int> and <https://example.com> fail an @mdx-js/mdx@3 compile. Unchanged from before the escaping existed — the same bytes reach the page — but not closed by it either.
  • An unbalanced code fence in a prose block can pair with the Mermaid fence that follows. Reads as a lint rule about a malformed prose block; tracked in Lint: an unclosed code fence in block prose swallows the rest of the rendered document #39.

Review

Three rounds. Round 1 was a real /code-review high; rounds 2 and 3 were approximations (reviewer subagents plus an adversarial binary pass) run while the repo owner was away, and are labelled as such in each record. Every finding was reproduced independently before being recorded — including one published repro that did not reproduce and was corrected.

Round records: r1 · r1 fixes · r2 · r2 fixes

jbeda added 3 commits July 26, 2026 18:07
`sanitize` passed `<`, `>` and `%%` through untouched. A role carrying a
`%%{init: ...}%%` directive was interpreted rather than drawn: confirmed
against mmdc 11, it restyled the whole diagram and swallowed its own label.
A role containing `<angle>` was parsed as a tag and vanished from the
rendered diagram with no diagnostic.

Runs of two or more percent signs collapse to one, which a directive cannot
be built from while a lone "50% full" still reads. `&`, `<` and `>` become
character references, in that order so an ampersand the author wrote is not
fused with a reference this function introduced; Mermaid decodes them back,
so the reader sees exactly what the author typed.

testdata/hostile_role.golden.mmd deliberately pinned the pre-fix behavior --
its comment said fixing #29 must replace it. This does, and the test comment
now describes the fixed contract instead.

Fixes #29

Signed-off-by: Joe Beda <joe@stacklok.com>
…#35)

Two gaps in the same class, both reachable from any unconstrained string.

A backtick in a code-spanned field closed its span early and dropped the
remainder into the document as live Markdown/HTML (#35). The `codeSpan`
helper from #34 already fences past backtick runs, so this applies it at the
call sites that wrap a schema-unconstrained value: attribute names, enum
value names, action names, and an action's actor. Entity, enum and glossary
keys, `subtypeOf`, a relationship's target and an invariant id are pinned by
schema patterns that exclude a backtick, and `render` validates structurally
before it renders, so they stay as they are.

Table cells route through a new `codeCell`, since a pipe in a name ended the
cell. GFM recognizes a backslash-escaped pipe inside a code span.

Separately, prose fields (`definition`, `description`, a role, a note, an
invariant statement, a scenario step) emitted raw HTML. They stay Markdown --
models backtick their entity names throughout -- but an angle bracket in one
is now a character reference, so a tag renders as the text the author typed.
The pass is code-span aware: inside a span `<x>` is already literal and
escaping it would put a visible "&lt;x&gt;" on the page. An ampersand is
escaped only where it introduces a character reference. ADR-0014 records the
contract and the reasoning.

No committed golden changed: no example holds a backtick or an angle bracket
in any affected field.

Fixes #35

Signed-off-by: Joe Beda <joe@stacklok.com>
Records the rendering-contract change behind the #35 prose fix: what a
definition: means when rendered, why Markdown stays and raw HTML does not,
and why the ampersand rule differs between the two renderers. Ties it to
ADR-0010's vendoring boundary and ADR-0011's offline render.

Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda

jbeda commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 1 record

Source: real /code-review high #37, run by @jbeda. Not an approximation.

Two HIGH findings that defeat the security rule ADR-0014 states, two LOW. Both HIGHs verified end to end with the real binary and a real Markdown parser (marked@12).

F1 (HIGH) — markdown.go:476 — a backslash-escaped backtick opens a fake code span

prose treats any ` as a code-span delimiter, but CommonMark says \` is an escaped literal and opens nothing. Raw HTML between two of them ships live. Reachable from every prose field, including one-line ones (table cells, roles, steps).

Reproduced independently:

attributes:
  - name: rate
    type: string
    description: "Escaped tick: \\` then <b>bold</b> then \\`."
| `rate` | string | Escaped tick: \` then <b>bold</b> then \`. |

The <b> is unescaped.

F2 (HIGH) — markdown.go:503codeSpanEnd scans across blank lines

A code span cannot cross a paragraph break in CommonMark; the scanner has no notion of block structure. Hits the five block-level fields that skip oneLine (model.description, enum.description, entity.definition, entity.derivation, scenario.description) — exactly where multi-paragraph prose lives.

The repro in the review does not reproduce — it has a single \` and so nothing to span to; that input escapes correctly. The mechanism is two bare backticks separated by a paragraph break. Corrected fixture:

definition: |
  The rate is quoted per hour; a trailing ` marks a provisional rate.

  Legacy importers still emit <img src=x onerror=alert(1)> in the notes.

  A second ` appears here, closing nothing legitimate.

Renders Legacy importers still emit <img src=x onerror=alert(1)> in the notes. — raw. Finding CONFIRMED, published repro corrected.

F3 (LOW) — markdown.go:480 — prose escapes inside block-level code

</> get escaped inside ~~~ fences and 4-space indented blocks, producing the visible &lt; that ADR-0014 says the design exists to avoid. Backtick fences survive by accident, because codeSpanEnd happens to match them as a span.

F4 (LOW, test quality) — markdown_test.go:410 — the guard shares the bug

TestRenderCodeSpans_HostileNamesStayInsideTheirSpan strips code spans with stripCodeSpans, which reimplements the same naive fence-matching as codeSpanEnd. Any divergence between the renderer's code-span model and a real Markdown parser is invisible to the test — which is exactly why F1 and F2 passed CI.

Root cause

F1 and F2 are one bug: codeSpanEnd is a character scanner with no model of Markdown block structure or backslash escapes. Fixing the two reported symptoms without addressing that leaves the next corner open.

Verified sound — not everything needs redoing

  • The Mermaid half (Render: sanitize lets Mermaid directives and angle brackets through from role text #29) holds. The reviewer ran mmdc 11 directly: &amp; &lt;angle&gt; decodes to visible label text, %{init:...}% stays inert (no theme fill in the SVG). It also probed #60;/#62; entity codes as a bypass — mermaid decodes them but HTML-escapes the result into the SVG, so they render as text. No injection.
  • The &-escaping rule round-trips for &amp;lt;, &&lt;, &lt, &#x3c;, and bare R&D.
  • codeSpan fence widening and padding are correct for all-backtick and mixed-run inputs; codeCell's \| is valid GFM.
  • Fields left unescaped (inv.ID, glossary/enum/entity keys, subtypeOf, rel.Entity, cardinality, ownership) are schema-constrained, and render does run lint.Structural first (main.go:210) — the PR's claim there checks out.
  • No golden drift; autolink loss and MDX { are recorded as accepted consequences in ADR-0014.

Fix round pending a design decision on how to fix the root cause. Staying in draft.

jbeda added 2 commits July 26, 2026 18:42
The escaping pass tracked code spans with a character scanner. It had no
model of block structure or of backslash escapes, so it read an escaped
"\`" as a delimiter and let a span run across a paragraph break — two
ways to declare raw HTML literal that CommonMark never would, both
shipping a live tag into the published page. It also escaped inside
"~~~" fences and indented code blocks, putting the visible "&lt;" on the
page ADR-0014 exists to avoid; a backtick fence survived only because the
scanner happened to match it as a span.

Delegate the semantics to goldmark. The parse locates the literal
regions and the escaping writes back into the original bytes at those
offsets — goldmark's renderer is never run, because re-rendering would
reflow the author's prose and churn every committed .md. The parser is
assembled from parser.NewParser rather than goldmark.New so the HTML
renderer never enters the binary; no committed .md changes.

Where a value lands decides what is literal there. A description emitted
as its own block can hold a code block; a table cell, a heading or a list
item cannot open one partway through a line, so a value that looks like a
fence when parsed alone is ordinary text in the document, and treating it
as code would leave the HTML behind it live. Hence proseBlock and
proseLine, and an entity derivation — emitted after "**Derived:** " —
takes the line form.

The old guard reimplemented the scanner's own matching, so it shared the
bug and passed on both inputs. Assert against goldmark instead: parse the
rendered document with a second implementation and require that no raw
HTML node survives, that no payload reaches the text outside a code span,
and that heading, link and table-cell counts are what the renderer meant
to emit.

Signed-off-by: Joe Beda <joe@stacklok.com>
The record described the hand-rolled scanner and the consequences that
followed from it. Rewrite it in place — it is unmerged, so there is no
shipped decision to supersede — around what the code now does, and fold
in the dependency call: why a parser rather than a better scanner, what
it costs a single-binary tool, and what was rejected.

Every accepted consequence was re-checked against the binary rather than
carried forward. The autolink loss and the untouched MDX brace still
hold; the goldens still do not churn. Three are new: the dependency's
measured cost and its clean bill under ADR-0011, a code block in a
block-level field now reaching the page verbatim, and an entity
derivation collapsing onto one line.

Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda

jbeda commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 1 fixes, independently verified

All four findings fixed in 2 signed-off commits (1e45e49, 2eeeac9). The hand-rolled scanner is gone: codeSpanEnd and backtickRun are deleted, goldmark locates literal ranges, and escaping writes back at those byte offsets.

Fix Verified
F1 escaped backtick no longer opens a span Escaped tick: \ then <b>bold</b> then `.— was raw`
F2 span cannot cross a paragraph break Legacy importers still emit &lt;img src=x onerror=alert(1)&gt; — was raw
F3 ~~~ and indented blocks stay verbatim fell out of the parse; confirmed, not assumed
F4 the guard no longer shares the bug stripCodeSpans deleted; assertions parse the rendered doc with an independent GFM goldmark

Earlier prose rules still hold, re-checked: R&D passes byte-for-byte, `Code<x>` untouched inside a span, &lt;b&gt; character reference neutralized.

The renderer is never run. Only goldmark/ast, goldmark/parser and goldmark/text are imported; the parser is built via parser.NewParser rather than goldmark.New, so the HTML renderer never links into the binary. Confirmed by import inspection, not just by the claim.

F4 was teeth-checked. The fix agent temporarily reverted literalRanges to the naive scan and confirmed TestADR_0014_NoRawHTMLSurvivesRender fires on both HIGHs. A guard that shares the implementation's assumptions is what let F1 and F2 through CI; this one doesn't.

Gates

task check green. task mermaid-check green (17 blocks — not part of task check, CI runs it). TestADR_0011_OfflinePackages PASS — goldmark pulls only net/url, which the test allows by name. goldmark is pure Go, pinned v1.8.4, +615 KiB. Zero golden churn — the only .md changed anywhere in the branch is ADR-0014 itself. Mermaid commit a796297 untouched.

Judgment calls for @jbeda to validate

1. A block-vs-line distinction the brief did not specify. Parsing a one-line value standalone makes ```<img …> look like a code fence whose info string is the tag; treated as literal it lands live in a table cell. So proseLine refuses block-level code, and entity derivation: moved to it.

Consequence: a multi-paragraph derivation: now collapses onto one line.

| `spotCount` | integer | _Derived:_ The number of spots assigned to this account.  Counted at the moment of the query, not cached. |

I think this is right rather than a loss — a GFM table cell cannot contain a paragraph break under any implementation, so the alternative is a broken table. No golden moved.

2. Residual, unfixed and pre-existing: an unclosed fence in block prose swallows the rest of the document. Verified byte-identical between a main-built binary and this branch, so not a regression. The tag inside is inert (a Markdown parser treats fence content as literal even unterminated), but **Attributes**, the table and the mermaid block all end up inside the code block, which would break a Docusaurus build. goldmark exposes no closure flag on FencedCodeBlock, so detecting it in the renderer means hand-scanning again — the wrong layer. Filed as a lint-rule issue rather than widening this PR.

Round 2 next: an approximation, since /code-review is user-invoked and @jbeda is away. Staying in draft.

@jbeda

jbeda commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 2 record

Source: an APPROXIMATION of /code-review, not the real thing. /code-review is user-invoked and an agent cannot launch it; @jbeda is away and asked for progress in his absence. Round 1 was a real /code-review high. Three finder subagents over c0de940..HEAD (the goldmark rewrite only — the Mermaid commit a796297 was verified in round 1 and is untouched).

Angle Model
Black-box adversarial, output checked with marked@12 Opus
White-box byte-offset and parser-config correctness Opus
ADR accuracy and dependency hygiene Sonnet

Every finding below was reproduced independently by the orchestrator before being recorded. Two are regressions this PR introduces.

Findings

Severity Regression? Finding
H1 HIGH yes render panics on block prose containing >, tab, backtick. The model lints clean; a main-built binary exits 0, this branch exits 2 with a Go stack from goldmark/parser.(*fencedCodeBlockParser).Openfcode_block.go:39 indexes line[BlockOffset()] with no -1 guard on unnormalised input.
H2 HIGH yes A blockquote marker > is escaped to &gt;, so the blockquote stops being one. The indented code block inside it — which literalRanges had marked literal — collapses into a paragraph, and its contents ship live. Base renders the same input as <pre><code>, i.e. safe.
H3 HIGH no role+note, actors[], preserves[]+description are escaped separately then joined onto one line. A stray backtick in the earlier piece pairs with the later piece's backticks, leaving its content outside any code span. Byte-identical on base — pre-existing, untouched by this PR.
H4 HIGH no Parser mismatch: markdown.go:471 parses plain CommonMark, markdown_test.go:31 parses GFM. GitHub and Docusaurus both render GFM, so the guard has been proving a property the shipped code does not have. GFM's table transformer dissolves a CommonMark code span, shipping a live tag.
M1 MEDIUM yes CommonMark's angle-bracket link-destination form [x](<url>) gets its brackets escaped, producing a dead href.
L1/L2 LOW yes Blockquotes lost in prose; autolinks render with stray brackets. Same cause as H2.
L3 LOW ADR-0014 says goldmark's "only net import is net/url"; net/netip comes transitively too. Already pre-allowed by the offline test — an imprecise sentence, not a boundary change.

One root cause behind H2, M1, L1 and L2

escapeMarkup is a blocklist. literalRanges collects code spans and code blocks, and everything outside those ranges has its <, > and entity-& escaped. That escapes Markdown structure, not just content — and escaping structure changes how the document parses, invalidating the ranges computed from the original parse.

The fix is to invert it to an allowlist, which is what the implementation brief originally specified:

ast.RawHTML / ast.HTMLBlock → escape. ast.CodeSpan, fenced and indented code blocks → verbatim. Everything else → unchanged bytes.

Escaping only nodes that are raw HTML never touches structure, so parse-then-write-back stays sound. It is also the more defensible rule: "escape exactly what we recognised as dangerous" rather than "escape everything we did not recognise as safe."

Verified clean

Offset arithmetic (200k-case fuzz), UTF-8 handling, idempotence, determinism, node-kind coverage for the ranges it does collect, and the field-routing table — every field that reaches output unescaped is still schema-constrained. goldmark is pure Go; go tool nm on the branch binary shows only ast/parser/text/util linked with no renderer, html or extension packages; size delta ~617 KiB matches the claim; no surprise transitives; .goreleaser.yaml, the SBOM step and action.yml need no changes. 32 further adversarial probes held.

Where this leaves the PR

Worse than it looked after round 1. It currently introduces a crash and a live-HTML regression, neither of which CI catches. A fix round follows. That will exhaust the three-round cap, so the result wants a real /code-review high before merge, not another approximation. Staying in draft.

For @jbeda to decide, flagged not guessed

Under an allowlist, a javascript: scheme in a link destination or autolink is not raw HTML and so would not be escaped. That is a genuinely different vector from the one ADR-0014 addresses. It needs either handling or an explicit exclusion in the ADR — silence would leave the ADR overclaiming.

jbeda added 4 commits July 26, 2026 19:32
`modelith render` crashed with a Go stack trace on a model that lints
clean. An entity definition ending "> \t`" exits 2 with

    panic: runtime error: index out of range [-1]
    goldmark/parser.(*fencedCodeBlockParser).Open

goldmark measures a line's indent in columns and its length in bytes,
then calls the line blank when the indent reaches the length. A tab
expands to four columns, so a short final line with one in it outruns
itself, gets marked blank with the -1 that means "no offset", and the
fenced-code-block parser — dispatched because the byte after the indent
is a backtick — indexes the line at -1.

Upstream yuin/goldmark#556 is the same -1 reached by a different path.
It is fixed, and v1.8.4 carries the fix, but the fix only swapped
BlockIndent() for BlockOffset() and both return -1 from the same
mismatched comparison: v1.8.4 still panics on #556's own reported repro.
There is no release to upgrade to.

So normalise the input instead of guarding the library. A document is
defined to end with a line ending, and appending one keeps the column
count from outrunning the byte count. It moves no offset inside the
string, so the escaping still writes back at the positions the parse
reports. 7.4 million fuzz executions over the normalised input found no
remaining panic; the raw input crashes in seconds.

Signed-off-by: Joe Beda <joe@stacklok.com>
The escaping was an allowlist of what to leave alone: a parse collected
the code spans and code blocks, and every byte outside them had "<", ">"
and an entity "&" escaped. That escaped Markdown structure. A ">" is a
blockquote marker, and turning it into "&gt;" stopped it opening one, so
the indented code block inside the quote — which the parse had just
reported as literal — collapsed into a paragraph and shipped its
contents live. The rule invalidated the very offsets it wrote at. It
also killed the "<...>" around a link destination, leaving a dead href,
and the pointy-bracket autolink form.

Invert it. Walk the AST and escape the byte ranges of the raw-HTML nodes
— ast.RawHTML, ast.HTMLBlock — and a code fence's info string, which
reaches the page as an attribute rather than as text and is inert to
block structure. Leave every other byte as the author typed it. Escaping
exactly what a parser called markup cannot disturb the parse it came
from: it only ever removes angle brackets, never adds one.

An "&" is no longer escaped outside a tag. That was fidelity rather than
safety — a character reference is text to every parser and can never
become a tag — and it is not worth a rule reaching beyond the markup.
Inside a tag it is escaped along with the brackets, so the tag reaches
the page byte-for-byte.

A line assembled from several author-written fields is now escaped once,
as the finished line. Whether a backtick opens a code span depends on
what else is on the line, so escaping a `role:` and the `note:` beside
it separately let a stray backtick in the role pair with the note's
backticks and leave the note's tag outside every span, live. That was
not a regression — it renders byte-identically on main — but ADR-0014
claims raw HTML does not survive a render, and it did.

A line-context value is parsed behind a two-byte stand-in for the "- ",
"# " or "| " it always follows, so a leading four-space indent or "```"
run cannot pretend to open a code block the rendered line has no room
for.

No committed golden changes.

Signed-off-by: Joe Beda <joe@stacklok.com>
The decision section described the rule the renderer no longer has. It
claimed everything outside a literal region was escaped, which is what
broke blockquotes and link destinations; it claimed the pointy-bracket
autolink form was lost, and with it `<javascript:alert(1)>`, which is now
the opposite of true.

Restate the rule as the allowlist it became, record what that preserves
that the old rule destroyed, and add the shared-line rule and the
fence-info case. Correct the goldmark import claim: the parser pulls
`net/netip` through `net/url`, and the offline test allows both by name.

Add a "Not decided here" section, because an ADR whose whole claim is
that raw HTML does not survive a render has to say where its rule stops.
Three things sit outside it, all measured rather than asserted: a link
scheme, which the allowlist made a live question by letting the autolink
form survive; MDX, which reads a bare `<` as a JSX tag and fails a
compile on a conceptual type that is unchanged from before the escaping
existed; and the CommonMark/GFM parser mismatch, which stays open
because `goldmark/extension` cannot be taken parser-only.

Signed-off-by: Joe Beda <joe@stacklok.com>
A value on a line was parsed behind a stand-in for the text it follows,
so it was always read as inline content. That is right for a heading and
a table cell and wrong for a list item: a scenario step renders as "1. "
and then the step, and the step opens the list item's first block. An
HTML block opens there on a tag that never completes, so a step of
"</div " was read as text, escaped nothing, and reached the page as an
open HTML block.

Read it both ways and escape what either reading calls markup. Neither
covers the other — dropping the stand-in instead would let a heading of
">     <script>" be read as a blockquote holding an indented code block,
which is code in a list item and live text in a heading. Over-escaping
costs a visible "&lt;" in a case nobody writes; under-escaping ships the
tag.

Merging the two readings means ranges can now overlap, so the merge
extends an interval instead of discarding the one that starts inside its
predecessor, which would have dropped the tail of the longer range.

Found by fuzzing escapeProse and the whole render against a GFM parser;
4.5 million executions are now clean outside the two classes ADR-0014
records as open. One of those is newly written down: a prose block that
leaves a code fence open parses as code on its own and pairs with the
Mermaid fence once it is in the document. Both are unchanged from main
rather than introduced here.

Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda

jbeda commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 2 fixes, independently verified. Loop cap reached with one HIGH still open.

Four signed-off commits (d35ccc3, 36c10e9, a99d81d, 494d863). All gates green: task check, task mermaid-check (17 blocks), TestADR_0011_OfflinePackages, go tool nm = 0 renderer/html/extension symbols, zero golden churn (the only .md changed in the branch is ADR-0014).

Status Verified
H1 panic FIXED d35ccc3 panic.modelith.yaml exits 0 and renders sanely, not just without crashing
H2 blockquote FIXED 36c10e9 > marker preserved, so the inner block is code again and the script is inert — matches base
H3 cross-field join FIXED 36c10e9 Rendered as \<img src=q onerror=alert(1)>` in the UI` — was live
M1 link destination FIXED 36c10e9 [click](<http://example.com/a>) intact
L1/L2 FIXED 36c10e9 fell out of the inversion
L3 + ADR FIXED a99d81d consequences re-derived, net/netip corrected via go list -deps
H4 GFM parser STOPPED, NOT FIXED see below

Earlier prose rules re-checked and still holding: <b> escaped, R&D byte-for-byte, `Code<x>` untouched inside a span, escaped-backtick case fixed.

H1: the upstream fix exists and does not work

goldmark#556 is the same -1, and v1.8.4 was tagged an hour after that fix commit — so the pinned version already carries it, yet #556's own repro still panics on v1.8.4. The fix only swapped BlockIndent()BlockOffset(); both return -1 from w >= len(line), comparing indent columns against bytes. v1.8.4 is the newest v1 release. Worked around by appending the trailing newline a document is defined to have — it moves no offset, so write-back is unaffected. 7.4M fuzz executions clean.

H4 is open, and deliberately so

Aligning the implementation to GFM cannot be done parser-only: every extension file imports root goldmark, whose eager defaultMarkdown global links renderer/html. Measured at 27 symbols vs 0 today, with control probes ruling out a false negative. The only clean path is vendoring the table transformer — a dependency and licence call that is @jbeda's, not an agent's.

So the implementation still parses CommonMark while GitHub and Docusaurus parse GFM, and GFM's table transformer can dissolve a CommonMark code span and ship a live tag. Pre-existing, not a regression — but it is a real open HIGH, and the three-round cap is now spent.

Also open, recorded not fixed

  • An unclosed fence in a block field pairs with the Mermaid fence — new, byte-identical on main. Reads as a lint rule; same family as Lint: an unclosed code fence in block prose swallows the rest of the rendered document #39.
  • MDX: measured with @mdx-js/mdx@3, map<string, int> and <https://example.com> both fail to compile. Pre-existing — base emits the same bytes, verified — and the PR strictly improves matters for real tags. But the allowlist by design leaves non-tag angle brackets alone, and those are exactly what breaks a Docusaurus build.
  • No upstream issue filed on yuin/goldmark. Outward-facing and @jbeda is away, so the agent correctly did not file one. The repro is two lines and it is worth filing.

Judgment calls for @jbeda to validate

  1. & fidelity dropped. Following "leave every other byte unchanged", an author who writes &lt; now sees < rather than &lt;. Safe — a character reference cannot produce markup — but a deliberate loss that moved four test expectations.
  2. H3 was fixed by assemble-then-escape, not the approach the brief suggested. The fix agent validated the brief's backtick idea first and rejected it (it double-escaped an author's \` and left a residual cross-field class). Parsing the assembled line whole already reports the payload as RawHTML, so no new machinery was needed. Better than what I proposed.
  3. H3 was in scope at all — my call, since ADR-0014 would otherwise have shipped false.

Recommendation

Run a real /code-review high #37 before merging. The loop is exhausted, one HIGH is open by design, and two of this round's four HIGHs came from angles nobody thought to specify in advance — the crash came out of fuzzing. Staying in draft.

The renderer parses CommonMark while every reader parses GFM, so GFM's
table transformer can dissolve a code span and let a tag through. Closing
it means vendoring goldmark's table transformer into a renderer-free
package: every extension file imports the root package, whose eager
global drags the HTML renderer in — 27 symbols against zero today.

Not worth it. Reaching the gap needs an author who is hostile and already
has commit access to the repository being rendered, who could edit the
generated Markdown directly instead.

Vendoring changes that, since it renders models from repositories this
one does not control. The answer there is to say so rather than to armour
the renderer: deps import and deps update will warn that vendoring
carries an injection risk and that models should only be vendored from
sources you trust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda
jbeda marked this pull request as ready for review July 27, 2026 16:01
@jbeda
jbeda merged commit cf7ec2f into main Jul 27, 2026
2 checks passed
@jbeda
jbeda deleted the fix/render-escaping branch July 27, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant