Package sanitize provides input-cleaning primitives and safe-emit builders for each markdown lexical slot. Realm authors wrap user- supplied strings with these helpers before flowing them into rendered markdown output. Each helper targets one specific slot (link text, heading text, URL href, table cell, HTML attribute, fenced code block, blockquote, footnote definition, link-reference definition, etc.) and neutralizes the bytes that would otherwise let user content break out of that slot or inject new top-level structure.
Pick the right helper from the table under "Picking the right helper" below, then wrap each user-supplied argument exactly once at the call site (see "The audit rule").
Wrap once
Most escapers and safe-emit builders in this package are NOT idempotent — applying them twice re-escapes bytes the first pass added (`\*` becomes `\\\*`, `&` becomes `&`, a fenced block gets re-fenced). Wrap each user-derived string with at most one sanitize.* call. Block and BlockRich are exceptions — idempotent by design — but the at-most-once rule is still the safest default. See the "Idempotence classes" enumeration below for the full breakdown.
Some markdown-builder packages (e.g. p/moul/md) sanitize the args of specific helpers internally — see each builder's package doc for the per-helper contract. If the builder sanitizes for you, pass the raw user input; if it doesn't, wrap the input with the right sanitize.* helper at the call site.
Picking the right helper
Match the helper to the slot the user content lands in:
Example
1slot helper
2-------------------------------------------------------------
3[text](url) InlineText (text)
4# Heading text InlineText
5**bold** _italic_ InlineText
6 InlineText (alt)
7> [!NOTE] one-line title InlineText
8multi-paragraph post body Block
9multi-paragraph post body w/ rich block BlockRich
10 structure (headings, lists, tables, etc.)
11multi-line blockquote (`> ` prefixed) Blockquote
12multi-line blockquote w/ rich block body BlockquoteRich
13[text](url "title") LinkTitle (title)
14| cell | TableCell
15<gno-card caption="X"> HTMLEscape
16<h5>X</h5> HTMLEscape
17any URL going into ](X) URL
18any image src going into (X) ImageURL
19`inline code` inside running prose InlineCode
20multi-line fenced code block CodeBlock
21multi-line fenced code with language tag LanguageCodeBlock
22[^name]: footnote body FootnoteDefinition
23[label]: url "title" reference def LinkReferenceDefinition
24r/sys/users handle UserName (validator)
25g1.../gpub1... etc. BechString (validator)
26footnote / LRD label / {#id} anchor name FootnoteLabel (validator)
27fenced-code language tag LanguageName (validator)
28prefix arg to md.Nested NestedPrefix (validator)
Invariants
All helpers in this package are panic-free for any string input and run in O(len(input)) time with bounded allocation.
Idempotence classes:
Example
1Idempotent (calling twice == calling once):
2 StripBidiAndZeroWidth, NormalizeBreaks
3 UserName, BechString, FootnoteLabel, LanguageName, NestedPrefix
4 URL, ImageURL (accept→identity; reject→"")
5 Block (bracket walker treats \[/\] as ordinary;
6 line-leader escapes don't re-fire on
7 already-escaped `\#` etc.)
8 BlockRich (TrimLeft/TrimRight + "\n\n" wrap is stable)
9
10NOT idempotent — never wrap an already-sanitized string:
11 InlineText, LinkTitle, TableCell (re-escape backslashes)
12 HTMLEscape (re-escapes `&` → `&`)
13 Blockquote, BlockquoteRich (re-prefixes `> `, nesting the quote each pass)
14 InlineCode, CodeBlock,
15 LanguageCodeBlock (wrap with a fence — calling twice double-wraps)
16 FootnoteDefinition,
17 LinkReferenceDefinition (compose Block/InlineText/URL internally —
18 passing already-sanitized strings double-escapes)
19
20CodeFence is pure: same inputs always give the same output.
Validators (UserName / BechString / FootnoteLabel / LanguageName / NestedPrefix) return either the cleaned input verbatim or "". They never partially-sanitize: if the input doesn't match the slot's charset/shape, the answer is rejection.
Composition rules
Direct sanitize use (when emitting markdown without a builder package):
Example
1out := "# " + sanitize.InlineText(userTitle) + "\n\n" +
2 sanitize.Block(userBody)
3out += sanitize.Blockquote(userQuote)
4out += sanitize.LanguageCodeBlock(realmLang, userCode)
Use with a builder package (e.g. p/moul/md): pass raw user input to the builder helpers that sanitize internally — do NOT pre-wrap with sanitize.*, or the input gets double-escaped (escapers are not idempotent). See the builder's package doc for the per-helper contract. For example, with p/moul/md:
Example
1md.Blockquote(userProse) // good — md.Blockquote sanitizes
2md.LanguageCodeBlock(realmLang, userCode) // good — sanitizes both args
3md.Link(userText, userURL) // good — sanitizes both slots
4
5md.Blockquote(sanitize.Block(userProse)) // BAD: double-wrap
6md.Link(sanitize.InlineText(t), sanitize.URL(u)) // BAD: double-wrap
Wrong (across all callers):
Example
1sanitize.InlineText(sanitize.InlineText(s)) double-wrap (re-escape)
2sanitize.TableCell(sanitize.InlineText(s)) TableCell already calls InlineText
3sanitize.URL(sanitize.InlineText(href)) inline-escape backslash-escapes `.` `-` `_`
4 inside the URL, corrupting the host/path
5sanitize.Blockquote(sanitize.Blockquote(s)) double-wrap — outer would escape the
6 inner `> ` prefixes
7sanitize.Block(sanitize.BlockRich(s)) double-sanitize — strict Block re-escapes
8 the markers BlockRich preserved (headings,
9 lists, tables); BlockRich's rich structure
10 renders as literal text after Block escapes
11 its line-leaders
12sanitize.BlockRich(sanitize.Block(s)) pointless double-sanitize — Block already
13 escaped every line-leader to `\#`/`\>`/etc.;
14 BlockRich preserves the backslash escapes
15 as visible artifacts in user prose
16sanitize.Blockquote(sanitize.BlockRich(s)) double-sanitize — Blockquote's Block step
17 re-escapes the markers BlockRich preserved
18sanitize.BlockRich(sanitize.Blockquote(s)) nonsense — Blockquote already line-prefixed
19 with `> `; BlockRich expects raw user content
20sanitize.BlockquoteRich(sanitize.BlockRich(s)) double-wrap — Rich + Rich nests twice
21sanitize.BlockRich(sanitize.TableCell(s)) wrong slot — use TableCell for cell content,
22 BlockRich for multi-paragraph block content
23sanitize.TableCell(multiParagraphProse) newlines fold to space silently; use a
24 non-table layout for multi-paragraph text
Threat model
Sanitizers in this package defend against:
- bidi/zero-width injection: invisible characters that make displayed text disagree with stored bytes (e.g. an address `g1abc...` that renders as `g1xyz...`, or a username that visually collides with another). Stripped by StripBidiAndZeroWidth, which runs as the first step of every text-shaped helper.
- line-ending homoglyphs: CR-only and Unicode separators (U+0085 NEL, U+2028, U+2029) that some renderers treat as line breaks. Folded uniformly.
- markdown-structure injection: user content opening a heading, blockquote, list, code fence, link-reference def, setext underline, gnoweb extension delimiter, or GFM table row at document level. Strict Block escapes the line-leading `|` of any GFM table row so user content cannot inject `<table>`-shaped structure; permissive BlockRich preserves table rows so authors can compose `<table>` elements (gnoweb loads extension.Table per render_config.go).
- HTML block type 1-5 absorption: CommonMark §4.6 HTML block types 1 (`<script>`, `<pre>`, `<style>`, `<textarea>`), 2 (`<!--`), 3 (`<?`), 4 (`<!UPPER`), and 5 (`<![CDATA[`) do NOT close on a blank line — they only close on a type-specific token (`</tag>`, `-->`, `?>`, `>`, `]]>`) or EOF. Without a defense, user content opening any of these would swallow realm chrome appended afterward. Both Block and BlockRich line-escape the openers (prepend `\`) so the block never opens; this defense is unconditional in both modes. Types 6 and 7 close on a blank line, so BlockRich's `\n\n` paragraph envelope already bounds them and no escape is needed.
- realm-discipline boundary (caller's responsibility, not enforced): callers should emit realm chrome at flush-left column 0 around `BlockRich(user)`. Indented chrome (4+ leading spaces, list-item continuations, footnote-definition body, or an unclosed Type 1 HTML tag in realm chrome before the call) can extend across blank lines into user content or vice versa. The sanitizer cannot defend against malformed realm chrome — only against user input.
- footnote / link-reference namespace pollution: user content containing `[^name]` or `[text][label]` syntax that would otherwise resolve against realm-defined footnote definitions or link reference definitions elsewhere on the page. Block escapes the opening `[` in both shapes.
- reference-link / footnote-ref / shortcut-ref collisions: `[text][label]`, `[^name]`, and bare `[label]` shortcut forms are ALL neutralized by Block's bracket walk, which preserves only inline `[text](url)` and `` syntax — everything else has both `[` and `]` backslash-escaped, so the parser sees literal text and can't resolve against realm-defined LRDs or footnote definitions.
- multi-line LRD evasion: Block's walker recognises `[lab\nel]: url` across newlines (single `\n` OK, blank line aborts) and strips the whole region. `\]` inside the label is honored as an escaped literal, so `[label\]: url` is NOT treated as an LRD (renders as literal text).
- URL scheme abuse: javascript:, data:text/html, vbscript:, blob:, protocol-relative //, mailto: with prefill phishing parameters. Allowlist-only (URL / ImageURL).
- HTML attribute / element breakout: `"`, `<`, `>`, `&`, `'` inside HTML lexical slots. Handled by HTMLEscape.
- CommonMark §2.3 NUL: replaced with U+FFFD by Block, InlineText, LinkTitle, TableCell, HTMLEscape, InlineCode, CodeBlock, and LanguageCodeBlock.
- code-fence leakage: a user-opened ``` ``` ``` fence that runs to EOF with no closing fence, which would otherwise swallow every realm- emitted line that follows. Block auto-closes any open fence at EOF.
- table-alignment drift: tabs inside table cells expanding to variable widths (1-4 spaces depending on column position) and shifting cell boundaries unpredictably. TableCell replaces tabs with single spaces.
What this package does NOT do:
- It does not store state. Every helper is a pure function.
- It does not validate semantic correctness. sanitize.URL accepts a syntactically valid https:// URL even if the host is malicious; URL reputation is a separate layer.
- It does not enforce CSS containment. ImageURL admits data:image/* URIs on the assumption that the deploying gnoweb instance caps rendered image dimensions via CSS. Without that cap, a malicious image can blow out the page layout or exhaust memory.
- It does not perform structural sandboxing of foreign markdown. If a realm concatenates an opaque markdown blob returned from a polymorphic interface (`someThing.Render()`), it needs a structural sandbox primitive (e.g. a `<gno-card>` extension), not just leaf sanitization.
When to use Block vs BlockRich
Both are safe sanitizers; both run identical realm-binding defenses. They differ in what user-authored block structure survives:
-
Block — paragraph-shaped only. Escapes `#`, `>`, list markers, `---`/`***`/`___` thematic breaks, and `===`/`---` setext underlines. Use for leaf slots — footnote definition bodies, table cells, blockquote bodies (Blockquote uses Block), single- paragraph prose, any slot where richer structure has no benefit or where richer structure could visually impersonate realm chrome.
-
BlockRich — full-richness. Preserves user-authored headings, lists, quotes, HR, setext. Use for user content the realm intends to compose with full block-level structure, typically inside a sandbox container (`<gno-card>`, `<gno-foreign>`) or a CSS-demoted region. BlockRich's qualifying-setext defense prevents the cross-boundary attack (user content reaching back to promote realm chrome to a heading), but inner-heading visual containment is the realm's CSS responsibility. gnoweb does not yet ship CSS rules that demote headings inside sandbox containers — until they land, BlockRich + sandbox renders inner headings at literal size.
Do NOT compose Block and BlockRich in either direction. Pick one helper at the right level.
Extending
A new helper added to this package MUST:
- Be panic-free for any string input.
- Strip bidi+zero-width before any other transform (so display equals storage end-to-end).
- Declare its idempotence class in the table above.
- Document the markdown / HTML lexical slot it targets.
- Reject rather than partially-sanitize when input is structurally invalid (return "" — never half-process an address or URL).
- Pick exactly one of the two return-value contracts and stick to it: escapers always return a transformed string and never reject (any input is OK — the transformation makes it safe); validators return the cleaned input verbatim on accept or "" on reject and never half-process. Mixing the contracts within one helper is a bug — callers can't reason about whether "" means "input was already empty" or "input was rejected".