Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v0 source pure

Package sanitize provides input-cleaning primitives and safe-emit builders for each markdown lexical slot. Realm auth...

Overview

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![alt](src)                                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 `&` → `&amp;`)
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 `![alt](src)` 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:

  1. Be panic-free for any string input.
  2. Strip bidi+zero-width before any other transform (so display equals storage end-to-end).
  3. Declare its idempotence class in the table above.
  4. Document the markdown / HTML lexical slot it targets.
  5. Reject rather than partially-sanitize when input is structurally invalid (return "" — never half-process an address or URL).
  6. 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".

Functions 23

func BechString

1func BechString(s, prefix string) string
source

BechString validates a bech32-style address-like string.

A bech32 string has the shape `<hrp>1<data>`: a human-readable prefix (HRP) that names the family (e.g. `g` for gno addresses, `gpub` for gno pubkeys, `cosmos` for cosmos addresses), the separator character `1`, then a data part carrying the encoded payload as lowercase alphanumerics.

If prefix != "", requires s to start with prefix+"1" exactly, and the data part to match ^[a-z0-9]{6,90}$. Use this when you know the expected family:

Example
1sanitize.BechString(addr, "g")     // only g1...     (addresses)
2sanitize.BechString(pk,   "gpub")  // only gpub1...  (pubkeys)

If prefix == "", accepts any reasonable bech32 shape: ^[a-z]{1,16}1[a-z0-9]{6,90}$.

Syntactic only — does NOT verify the bech32 checksum. Use a true bech32 decoder if you need that. Returns the cleaned input on accept, "" on reject; on "" return, do not emit the address-link markup (the user-supplied bytes have failed shape validation and should not appear unmodified in output).

func Block

1func Block(s string) string
source

Block prepares user content for a top-level BLOCK markdown context where paragraphs, line breaks, code blocks, and other block structure should survive — but where the content must NOT be able to inject new top-level constructs (headings, lists, blockquotes, link-reference definitions, setext underlines, gnoweb extension delimiters, GFM table rows).

Output shape: every non-empty result begins AND ends with "\n\n" — CM §4.8 blank lines on both sides — so user content is guaranteed to occupy its own paragraph(s), isolated from any realm chrome that precedes OR follows it. This bounds CM §4.6 HTML block types 6 and 7 (`<div>`, `<table>`, `<form>`, arbitrary `<foo>` tags) which close on a blank line and are NOT escaped in any mode, and it defeats first-line setext promotion (`===`/`---`) that strict-mode escapes miss when the previous line is blank in the user input but non-blank in the concatenated realm output. Empty input (or input that strips entirely, e.g. a lone LRD) returns "" — no envelope is emitted.

Use for any multi-paragraph user-supplied prose that the realm concatenates into its rendered output:

  • post bodies, comments, replies
  • profile bios, About sections
  • proposal descriptions, governance motions
  • changelog entries, release notes

What Block does with each kind of attacker input:

Example
 1User attempt                                          | Block's response
 2------------------------------------------------------|----------------------------------------------------
 3  --- preserved verbatim ---                          |
 4[text](url) inline link, ![alt](src) image            | preserved verbatim
 5------------------------------------------------------|----------------------------------------------------
 6  --- escaped / stripped / folded ---                 |
 7# heading at line-start                               | escaped → literal `# heading`
 8> quoted at line-start                                | escaped → literal `>`
 9- item, * item, + item, 1. item at line-start         | escaped
10---, ***, ___ (3+) at line-start                      | escaped
11=== or --- on its own line after non-blank text       | escaped (no setext promotion of the line above)
12<gno-card>, <gno-columns>, any <gno-…>/</gno-…> at    | escaped (wildcard match) → literal text
13  line-start                                          |
14| a | b | GFM table row (line-leading `|`)            | escaped → literal `| a | b |`
15<!--, <script>, <pre>, <style>, <textarea>, <?…?>,    | escaped (\<…) → literal text;
16  <!DOCTYPE…>, <![CDATA[…]]> at line-start            |   blocks goldmark from opening a
17  (CM §4.6 HTML block types 1-5)                      |   blank-line-NON-terminating HTML block
18[text][realm-label] ref-link USE                      | both bracket pairs escaped → \[text\]\[realm-label\]
19[^name] footnote-ref                                  | both brackets escaped → \[^name\]
20[label] bare shortcut-ref                             | both brackets escaped → \[label\]
21[label]: url link-reference definition                | whole region stripped (incl. multi-line label
22  (incl. [lab\nel]: url multi-line)                   |   `[lab\nel]: url` and any title continuation)
23[label\]: url (backslash-escaped `]`)                 | NOT stripped; brackets escaped → paragraph text
24code fence opened without close                       | autoclosed at end of input
25NUL byte (\x00)                                       | replaced with U+FFFD
26U+2028 / U+2029 / U+0085 (NEL)                        | folded to `\n`
27bidi/zero-width controls                              | stripped

COMPOSITION GOTCHA: Block's EOF fence-autoclose appends a final fence line. If you wrap Block's output with a line-prefixing builder like md.Blockquote (which prepends `> ` per line) or md.Nested, that closing fence becomes a prefixed line. The output is still safe (the fence still closes correctly) but may render awkwardly. If pixel-perfect output matters, strip a trailing blank fence line after Block.

Why backslash and not a space for `<gno-…>` lines: gnoweb's extension parsers call `util.TrimLeftSpace` on the line before tag matching, which would strip a leading space and let the tag match anyway. A leading `\` survives the trim (only ASCII whitespace + form-feed are stripped) and is consumed by the inline escape phase before Type-7 HTML block detection can fire (Type-7 requires the first non-whitespace char to be `<`).

Inline emphasis, code spans, inline links, and soft line breaks within a paragraph are PRESERVED — users can format. Pipes that are NOT at line-start stay literal so prose can still write things like `a | b`.

Idempotent: Block(Block(s)) is byte-identical to Block(s). The bracket walker strips LRDs on the first pass; remaining `[`/`]` outside inline-link/image spans are escaped to `\[`/`\]`, and already-escaped brackets are preserved on subsequent passes (pass-2 backslash-parity tracking). Still, wrap each user-supplied string exactly once — chained sanitization adds no value and burns gas.

func BlockRich

1func BlockRich(s string) string
source

BlockRich is the permissive counterpart of Block. Both are safe sanitizers — the distinction is what markdown structure survives:

  • Block escapes line-leading block markers (`#`, `>`, `-`, `*`, `+`, `1.`), thematic breaks (`---`/`***`/`___`), and setext underlines (`===`/`---`). User content becomes paragraph-shaped.
  • BlockRich PRESERVES all of those, so user content can compose headings, lists, quotes, horizontal rules, and setext-styled headings. Realm-binding defenses stay on (extension delimiters `<gno-…>`, the bracket walker for link / LRD / ref / footnote / shortcut, fence autoclose, NUL / bidi / Unicode-separator folding). GFM table-row openers are PRESERVED (see "Tables" below).

Cross-paragraph safety: BlockRich's output begins with "\n\n" AND ends with "\n\n" — CM §4.8 blank lines on both sides — so user content is guaranteed to occupy its own paragraph(s), isolated from anything the realm emits before OR after. Symmetric isolation closes four distinct attacks:

  • Cross-paragraph setext promotion (backward). User content `body\n===\nmore` concatenated after realm chrome (no trailing `\n`) would, without paragraph isolation, place "chrome\nbody" in one paragraph; the `===` setext underline would then promote that merged paragraph to H1, hijacking realm chrome. The leading "\n\n" forces a paragraph break.

  • Cross-paragraph GFM table promotion (backward). User content beginning with `|---|---|` (a table delimiter row) would, without a blank-line break, retroactively turn the preceding realm line into a `<thead>`. Paragraph isolation prevents the table-detection scan from crossing the boundary.

  • Cross-paragraph GFM table promotion (forward). Realm chrome appended immediately after BlockRich(user) that begins with `|---|` would, without a trailing blank line, extend user's last line into a table header and pull realm chrome into the body row. The trailing "\n\n" prevents the merge.

  • Lazy paragraph continuation (forward). Paragraph-shaped realm chrome appended immediately after BlockRich(user) would, via CM §5.2, merge into user's trailing paragraph and inherit any block-level decoration it carries.

First-line qualifying-setext escape (the `neuterLeadingSetextIfQualifying` pre-pass) remains in place as belt-and-suspenders: if the first non-blank line of user input matches the CM §4.3 setext-underline pattern (run of `=` or `-` with 0-3 leading spaces and only trailing whitespace), BlockRich inserts `\` before the first `=`/`-`. This is redundant given paragraph isolation but harmless and inexpensive.

Tables

BlockRich preserves line-leading `|` so user content can compose GFM tables:

Example
1| Header A | Header B |
2|----------|----------|
3| cell a   | cell b   |

renders as a real `<table>` element. Strict Block continues to escape line-leading `|` (each row becomes literal `\| a | b |` text). When the realm authors the table itself and inserts user content into a specific cell, use TableCell — NOT BlockRich — to sanitize that cell value.

What attacker input produces what (full table, same rows as Block except where marked CHANGED):

Example
 1User attempt                                  | BlockRich response
 2----------------------------------------------|--------------------------------------------------
 3  --- preserved (compose freely) ---          |
 4# heading at line-start                       | preserved [CHANGED from Block]
 5> quoted at line-start                        | preserved [CHANGED]
 6- item, * item, + item, 1. item               | preserved [CHANGED]
 7---, ***, ___ thematic break                  | preserved [CHANGED]
 8=== or --- setext underline                   | preserved when preceded by user text;
 9                                              | escaped (\===/\---) if the first non-blank
10                                              | line of input [CHANGED]
11| a | b | GFM table row (line-leading |)      | preserved → renders as <table> when followed by
12                                              | a delimiter row [CHANGED]
13[text](url), ![alt](src)                      | preserved verbatim [SAME]
14----------------------------------------------|--------------------------------------------------
15  --- escaped / stripped / folded ---         |
16<gno-card>, any <gno-…>/</gno-…> at line-start| escaped (wildcard match) [SAME]
17<!--, <script>, <pre>, <style>, <textarea>,   | escaped (\<…) [SAME] — Types 1-5 don't close
18  <?…?>, <!DOCTYPE…>, <![CDATA[…]]>           |   on blank lines, so `\n\n` envelope
19  at line-start (CM §4.6 HTML block types 1-5)|   doesn't isolate them; explicit escape
20[text][realm-label] ref-link USE              | both pairs escaped [SAME]
21[^name] footnote-ref                          | both brackets escaped [SAME]
22[label] bare shortcut-ref                     | both brackets escaped [SAME]
23[label]: url link-reference definition        | whole region stripped [SAME]
24[label\]: url (escaped `]`)                   | not stripped; brackets escaped [SAME]
25code fence opened without close               | autoclosed at end of input [SAME]
26NUL byte (\x00)                               | replaced with U+FFFD [SAME]
27U+2028 / U+2029 / U+0085 (NEL)                | folded to `\n` [SAME]
28bidi/zero-width controls                      | stripped [SAME]

Use BlockRich for user content the realm intends to compose with full block-level richness — typically inside a sandbox container (`<gno-card>`, `<gno-foreign>`) or a CSS-demoted region where inner headings render visually distinct from realm chrome. The realm must own the visual containment: concatenating BlockRich's output directly into a top-level page still lets the user write `# heading` at document level. BlockRich's cross-boundary setext defense prevents the worst case (reaching backwards into realm bytes), but visual containment of inner headings is the realm's CSS responsibility. gnoweb does not yet ship CSS rules that demote inner headings inside `<gno-card>` / `<gno-foreign>` — until those rules land, realms using BlockRich + a sandbox should be aware that inner headings render at their literal level.

Idempotent: BlockRich(BlockRich(s)) is byte-identical to BlockRich(s). The TrimLeft-then-"\n\n"-prepend pattern strips any leading newlines and reapplies exactly two, so the leading shape is stable across passes; the qualifying-setext escape is stable (a line beginning with `\` no longer matches the setext pattern); and the bracket walker treats already-escaped `\[`/`\]` as ordinary bytes. Empty input (or input that strips to empty, e.g. a lone link-reference definition) returns "" — realm concatenation doesn't get a stray blank line. Still, wrap each user-supplied string exactly once — chained sanitization adds no value and burns gas.

Realm-discipline boundary: BlockRich defends user input against every cross-paragraph attack listed above, but it CANNOT defend against malformed REALM chrome. Specifically, callers should emit realm chrome at flush-left column 0 around `BlockRich(user)`. If the realm chrome BEFORE the call contains an unclosed CM §4.6 Type 1 HTML tag (`<script>`, `<pre>`, `<style>`, `<textarea>`), the `\n\n` envelope does NOT close it (Type 1 closes only on the matching close tag), and user-controlled `</tag>` content can then prematurely terminate it. Indented chrome (4+ leading spaces, list-item continuations, footnote-definition body) can likewise extend across the envelope into user content. Keep chrome flush-left and Type 1 tags closed within the chrome.

PREVIEW: BlockquoteRich is currently the only in-tree caller of BlockRich; the API and the `"\n\n"` output shape may evolve once direct callers emerge.

func Blockquote

1func Blockquote(text string) string
source

Blockquote wraps user content as a CommonMark blockquote: each line of the cleaned content gets a "> " prefix so the renderer displays it inside a `<blockquote>` element.

Use for any multi-paragraph user-supplied text that the realm wants to render as a quotation: cited posts, attached responses, error snapshots that should visually stand out.

The content is first cleaned by Block (bidi-strip, line-ending normalize, NUL→U+FFFD, bracket walker for link/image/LRD spans, block-marker escape, code-fence auto-close at EOF, Unicode-separator fold). Block's "\n\n" cross-paragraph envelope is then stripped — the `> ` marker creates the container boundary, so the envelope would only line-prefix to empty `> ` lines top and bottom — and every remaining line is prefixed with "> ". The user content can still use inline emphasis, code spans, and nested fenced code blocks inside the quote; what it cannot do is open new top-level structure (heading, list, blockquote, GFM table row, etc.) or escape the quote.

Output shape — every non-empty result begins with "\n" and ends with "\n\n" (same shape as BlockquoteRich):

  • Leading "\n" guarantees a clean blockquote opener even when the realm concatenates `chrome + Blockquote(user)` without its own newline separator.
  • Trailing "\n\n" (blank line) cleanly ends the blockquote so a realm appending `Blockquote(user) + chrome` cannot pull chrome bytes into the quote via CommonMark §5.2 lazy continuation.

Empty input (or input that strips entirely, e.g. a lone LRD) returns "" — no blockquote is emitted.

Composition gotcha: Block's EOF code-fence auto-close (added when user content opens a ``` ``` ``` fence without closing it) becomes a "> ```" line at the end of the blockquote. Goldmark parses this correctly as the close of a fenced block inside the quote — the output is structurally safe — but the markdown source looks unusual to a human reviewer. If aesthetic output matters, ensure user content closes its own fences.

Not idempotent (see package doc): wraps with `> ` per line, so calling twice double-wraps and the outer call's Block step escapes the inner `>` prefixes.

Do NOT compose with BlockRich in either direction:

  • Blockquote(BlockRich(s)) double-sanitizes: BlockRich preserves `#`/`>`/etc., then Blockquote's Block step escapes them again.
  • BlockRich(Blockquote(s)) doesn't make sense: Blockquote already line-prefixed with `> `; BlockRich expects raw user content.

For a quoted body that can contain headings, lists, nested quotes, or thematic breaks, use BlockquoteRich.

func BlockquoteRich

1func BlockquoteRich(text string) string
source

BlockquoteRich is the permissive counterpart of Blockquote. Both wrap user content as a CommonMark blockquote (each line prefixed with `> `), but they differ in what block-level structure inside the quote survives:

  • Blockquote escapes line-leading block markers, so the quoted body is paragraph-shaped — `# x` inside a Blockquote stays a literal `#`.
  • BlockquoteRich PRESERVES line-leading block markers, so the quoted body can compose ATX headings, lists, thematic breaks, nested blockquotes (`> > nested`), and other block-level structure. Realm-binding defenses stay on (extension delimiters, GFM table-row openers, bracket walker, fence autoclose, NUL / bidi / Unicode-separator folding).

Output shape — every non-empty result begins with "\n" and ends with "\n\n":

  • Leading "\n" guarantees a clean blockquote opener even when the realm concatenates `chrome + BlockquoteRich(user)` without its own newline separator. Without the leading "\n", chrome ending mid-line followed by "> quoted" would render `>` as literal paragraph text instead of opening a blockquote.
  • Trailing "\n\n" (blank line) cleanly ends the blockquote so a realm appending `BlockquoteRich(user) + chrome` cannot pull chrome bytes into the quote via CommonMark §5.2 lazy continuation. Without the trailing blank line, paragraph chrome immediately after BlockquoteRich would render inside the quote.
  • BlockRich's own leading "\n\n" (paragraph-isolation blank line) is stripped before line-prefixing — otherwise the output would carry one or two redundant empty `> ` quoted lines at the top. A single "\n" is then re-prepended at the BlockquoteRich boundary so `chrome + BlockquoteRich(user)` still lands the first `>` at column 0.
  • The cross-boundary setext defense BlockRich provides is redundant inside a blockquote: a setext underline inside `> ` content can only promote a line in the same blockquote, never reach realm bytes (different CM container). BlockRich still applies it, harmlessly.

What attacker input produces what (rows that differ from Blockquote are marked CHANGED):

Example
 1User attempt                                  | BlockquoteRich response
 2----------------------------------------------|------------------------------------------------
 3  --- preserved inside `> ` quote ---         |
 4# heading                                     | preserved as `> # heading` [CHANGED]
 5> nested quote                                | preserved as `> > nested quote` [CHANGED]
 6- item, * item, + item, 1. item               | preserved as `> - item` etc. [CHANGED]
 7---, ***, ___ thematic break                  | preserved [CHANGED]
 8=== or --- setext underline                   | preserved when preceded by user text;
 9                                              | escaped (\===/\---) if first non-blank
10                                              | line of input [CHANGED]
11| a | b | GFM table row (line-leading |)      | preserved → renders as <table> inside the
12                                              | blockquote when followed by a delimiter row [CHANGED]
13[text](url), ![alt](src)                      | preserved verbatim [SAME]
14----------------------------------------------|------------------------------------------------
15  --- escaped / stripped / folded ---         |
16<gno-card>, any <gno-…>/</gno-…> at line-start| escaped (wildcard match) [SAME]
17<!--, <script>, <pre>, <style>, <textarea>,   | escaped (\<…) [SAME] — CM §4.6 Types 1-5
18  <?…?>, <!DOCTYPE…>, <![CDATA[…]]>           |   don't close on blank lines; without escape
19  at line-start                               |   they would swallow chrome past the `> ` quote
20[text][realm-label] ref-link USE              | both pairs escaped [SAME]
21[^name] footnote-ref                          | both brackets escaped [SAME]
22[label] bare shortcut-ref                     | both brackets escaped [SAME]
23[label]: url link-reference definition        | whole region stripped [SAME]
24code fence opened without close               | autoclosed at end of input [SAME]
25NUL byte (\x00)                               | replaced with U+FFFD [SAME]
26U+2028 / U+2029 / U+0085 (NEL)                | folded to `\n` [SAME]
27bidi/zero-width controls                      | stripped [SAME]

Use BlockquoteRich when the realm wants to render user content as a quotation that itself reads like authored markdown — the visual CSS containment of `<blockquote>` already demotes inner headings relative to realm chrome, so the "inner headings need a sandbox" caveat that applies to BlockRich at top level does not apply here.

Not idempotent: like Blockquote, calling twice double-wraps — `BlockquoteRich(BlockquoteRich(s))` produces `> > content`, nesting the quote a level deeper each pass.

Empty input (or input that reduces to nothing after BlockRich, e.g. a lone link-reference definition) returns "" — no blockquote is emitted and neither the leading "\n" nor the trailing "\n\n" shape applies.

func CodeBlock

1func CodeBlock(content string) string
source

CodeBlock wraps user content as a CommonMark fenced code block. Use for any user-derived multi-line snippet that should render as a code block: log excerpts, JSON dumps, error backtraces, config snippets, posted code samples.

Behavior:

  • Bidi/zero-width controls are stripped.
  • CR/CRLF line endings are normalized to LF; Unicode separators (NEL U+0085, U+2028, U+2029) are folded to LF for line-count consistency.
  • NUL is replaced with U+FFFD per CM §2.3.
  • The wrapping fence is at least 3 backticks (CM §4.5 minimum) and sized to outscan internal backticks — an attacker cannot embed a closing fence in the content.

Empty content emits an empty fenced block ("```\n\n```\n"), which is valid CommonMark and renders as an empty `<pre><code></code></pre>`.

Not idempotent (see package doc).

func CodeFence

1func CodeFence(content string, minCount int) string
source

CodeFence returns a string of backticks long enough to wrap content as a CommonMark fenced code block without the content's own backticks closing the fence prematurely.

Returned length N = max(minCount, longestBacktickRunInContent + 1). Use N backticks both before and after the content:

Example
1fence := sanitize.CodeFence(userCode, 3)
2out += fence + "\n" + userCode + "\n" + fence + "\n"

Typical minCount values:

  • 1 for inline code spans (`x`)
  • 3 for block fenced code (CommonMark §4.5 requires ≥3)

`minCount < 1` is clamped to 1. Empty content returns strings.Repeat("`", max(minCount, 1)). Never panics.

Most realms should reach for InlineCode / CodeBlock / LanguageCodeBlock below, which call CodeFence internally and emit the full code block for you. Call CodeFence directly only when you're rolling a custom fence emitter (e.g. a renderer that needs the fence length but emits the body differently).

func FootnoteDefinition

1func FootnoteDefinition(name, text string) string
source

FootnoteDefinition emits a GFM footnote definition — the `[^name]: body` form that introduces a footnote whose body is rendered in the page footer (or wherever the renderer chooses to place it). Other parts of the markdown reference the footnote by writing `[^name]` inline.

Use for any realm-rendered footnote where the body text comes from user input. The realm picks the footnote name (passed as `name`, validated by FootnoteLabel — failure here returns ""); the user's content goes in `text`, which is sanitized via Block.

Contract:

  • `name`: passed raw, validated as a FootnoteLabel (^[A-Za-z0-9_-]{1,64}$). Reject → return "".
  • `text`: passed raw multi-paragraph user prose, cleaned via Block (bidi-strip, line-ending normalize, LRD strip, block-marker escape, ref-link USE escape, fence auto-close).

Empty body → returns "" (a label without body is not a valid footnote definition; the markdown would parse as a paragraph containing the label).

Output shape:

Example
1[^name]:
2    line 1 of body
3    line 2 of body
4    ...

The label sits on its own line and each body line gets a 4-space indent — the GFM continuation rule that keeps multi-paragraph body text bound to the footnote rather than detaching as a new paragraph.

Not idempotent (see package doc): composes Block internally; passing already-sanitized body text double-escapes.

func FootnoteLabel

1func FootnoteLabel(s string) string
source

FootnoteLabel validates an identifier used as a footnote name, link- reference-definition label, or {#id} anchor: ^[A-Za-z0-9_-]{1,64}$. Strips bidi/zero-width first. Returns s if valid, "" otherwise.

Use for every shape where a markdown identifier is treated as an opaque key by the parser:

  • footnote-definition labels: [^FootnoteLabel(name)]: body
  • footnote-reference labels: see [^FootnoteLabel(name)]
  • link-reference-definition labels: [FootnoteLabel(label)]: url
  • reference-link USE labels: [text][FootnoteLabel(label)]
  • goldmark auto-anchor {#id}: # Heading {#FootnoteLabel(id)}

The shared validator name reflects the shared charset and shared security goal — keep untrusted bytes out of any parser-managed identifier slot.

On "" return, omit the footnote / LRD / anchor entirely rather than emitting it with raw user bytes.

func HTMLEscape

1func HTMLEscape(s string) string
source

HTMLEscape prepares user content for an HTML lexical slot inside markdown — covers attribute values, element bodies, and HTML comment bodies:

Example
1<gno-card type="..." caption="X">         attribute value
2<gno-alert title="X">                     attribute value
3<h5>X</h5>                                element body
4<details><summary>X</summary>...          element body
5<!-- X -->                                comment body (safe: `>`
6                                          becomes `&gt;`, so user
7                                          cannot inject `-->`)

HTMLEscape escapes the union of attribute-breaking and body-breaking characters (`<`, `>`, `&`, `"`, `'`), so one function safely serves every HTML lexical context. Callers don't have to remember which subset to use for which slot.

Pick the right helper — markdown title and HTML attribute share the look but use different escape rules:

Example
1[text](url "X")              → LinkTitle      (markdown title)
2<span title="X">             → HTMLEscape     (HTML attribute)
3<h5>X</h5>                   → HTMLEscape     (HTML element body)

Swapping InlineText for HTMLEscape is wrong: markdown's backslash escapes survive into the rendered HTML as literal `\*`. Swapping LinkTitle for HTMLEscape is also wrong: `&` written inside a markdown title renders as the literal characters `&`.

Not idempotent (see package doc): calling twice produces `&` → `&amp;`.

func ImageURL

1func ImageURL(s string) string
source

ImageURL validates a URL for use as an image src. Kept separate from URL — not a parameterized variant — because the allowlist shapes differ qualitatively (data:image/* vs. mailto:) and a single boolean flag would invite callers to pass the wrong default.

Allowlist:

  • http, https
  • schemeless relative URLs starting with /, ./, or .. (rejects // protocol-relative — tracking-pixel vector)
  • data:image/svg+xml, data:image/png, data:image/jpeg, data:image/gif, data:image/webp

Any other data: subtype is rejected — data:text/html etc. would render as inline HTML and execute embedded scripts.

DEPLOYMENT PRECONDITION: data: URIs encode the bytes of the image directly into the markup, so a malicious sender can construct an image whose pixel dimensions are arbitrarily large at minimal byte cost. The deploying gnoweb instance MUST clamp rendered image dimensions via CSS (e.g. `max-width: 100%; max-height: <bound>`). Without that cap, a single image can blow out the page layout or exhaust the browser's memory.

Returns "" if the URL is empty after trim or fails the allowlist.

func InlineCode

1func InlineCode(content string) string
source

InlineCode wraps user content as a CommonMark inline code span — the `code` in “ `code` “. Use for any user-derived token, identifier, or short literal that should render in monospace inside running prose: variable names, hashes, hex addresses, token symbols, error codes, package paths, transaction IDs.

Inline code spans cannot span lines (a `\n` inside the content would end the span and leave the surrounding backticks as literal text), so all line breaks — CR / CRLF / LF, NEL (U+0085), U+2028, U+2029 — are folded to a single space. If you want each line of user content on its own row, use CodeBlock instead.

Behavior:

  • Bidi/zero-width controls are stripped (browsers honor bidi marks inside `<code>`, so leaving them would let stored bytes display as something different).
  • NUL is replaced with U+FFFD.
  • The wrapping fence is one backtick longer than the longest backtick run in the content, so internal backticks can never close the span prematurely.
  • A single space pad is added on each side when content starts or ends with “ ` “ or space, so leading/trailing backticks render literally rather than fusing with the fence (the renderer strips one space from each side per CommonMark spec).

Empty input returns "" rather than a literal two-backtick string (which CommonMark parses as text, not as an empty code span). If you use InlineCode as link text and it returns "", omit the link entirely.

Not idempotent (see package doc): wraps with a fence, so calling twice double-wraps.

func InlineText

1func InlineText(s string) string
source

InlineText prepares an arbitrary user string for an INLINE markdown slot — anywhere the rendered output stays on a single line and lives inside a larger markdown construct.

Use for:

  • link text: [InlineText(label)](url)
  • heading text: # InlineText(title)
  • bold/italic body: **InlineText(name)**
  • image alt text: ![InlineText(alt)](src)
  • single-line block-context slots: > [!NOTE] InlineText(title) > Author: InlineText(name)

Multi-paragraph prose belongs in Block, not InlineText. InlineText folds every newline to a single space (so paragraph structure is erased) and escapes inline-active CommonMark punctuation:

Example
1\ * _ [ ] ( ) ~ > - + . ! ` # < &

Two characters are intentionally NOT escaped:

  • `|` — only meaningful in GFM table rows. Leaving it literal here lets TableCell (which calls InlineText then escapes `|` itself) avoid double-escaping pipes into `\\|`.
  • `=` — only meaningful as a setext heading underline, which is a line-level construct. Escaping `=` inline would mangle expressions like `x = 1` for no benefit.

Not idempotent (see package doc).

func LanguageCodeBlock

1func LanguageCodeBlock(language, content string) string
source

LanguageCodeBlock wraps user content as a fenced code block tagged with a programming-language hint (the "info string" after the opening fence, e.g. `go` in ```` ```go ````) so the renderer can apply syntax highlighting.

An invalid `language` tag silently falls back to a tagless fence — the helper never returns an error or panics. If a realm author is debugging "why is my Go highlighting gone?", the input failed the language validator (charset ^[a-zA-Z0-9_+-]{1,32}$ after bidi-strip). This fallback exists because an unvalidated tag could contain a newline that injects content (e.g. a heading) onto what becomes the opening fence line.

Content is cleaned exactly as in CodeBlock (bidi-strip, CR/CRLF normalize to LF, NEL/U+2028/U+2029 fold to LF, NUL→U+FFFD, fence sized to outscan internal backticks).

Not idempotent (see package doc).

func LanguageName

1func LanguageName(s string) string
source

LanguageName validates the language tag (a.k.a. "info string") for a fenced code block — the `go` in:

Example
1```go
2fmt.Println("hi")
3```

Charset: ^[a-zA-Z0-9_+-]{1,32}$ — letters, digits, `_`, `+`, `-`, up to 32 bytes. Strips bidi/zero-width first.

Returns the cleaned input if valid, "" otherwise. A "" return means the caller should emit a language-less fence (``` without a tag) rather than letting the user pick the syntax highlighter — which could otherwise be used to inject newlines or block markers into what becomes the opening fence line.

func LinkReferenceDefinition

1func LinkReferenceDefinition(label, url, title string) string
source

LinkReferenceDefinition emits a CommonMark link reference definition (CM §4.7) — the `[label]: url "title"` form that other parts of the markdown reference by writing `[text][label]` or `[label]` (shortcut).

Use for any realm-rendered LRD where the realm owns the label but any of the URL or title come from user input. The user content for the URL goes through URL (allowlist-based — reject → ""); the title goes through LinkTitle (escape).

Contract:

  • `label`: passed raw, validated as a FootnoteLabel (^[A-Za-z0-9_-]{1,64}$). Realms should choose a namespaced label using dashes (e.g. `r-myrealm-help`) so shortcut-reference invocations from user content can't collide with bare prose (`[help]`, `[click here]`). `/` is not in the FootnoteLabel charset; reject → return "".
  • `url`: passed raw, sanitized via URL. If URL rejects, the LRD is skipped (return "").
  • `title`: passed raw, sanitized via LinkTitle. Empty title → no title clause emitted.

The output is framed with leading and trailing blank lines so that the definition cannot accidentally fuse with adjacent paragraph content into a setext underline or a continuation line.

Not idempotent (see package doc).

func LinkTitle

1func LinkTitle(s string) string
source

LinkTitle prepares user content for a CommonMark link-title or image-title slot — the optional quoted text after the URL in any of these forms:

Example
1[text](url "TITLE")
2![alt](src "TITLE")
3[label]: url "TITLE"

Escapes the inline-active set plus `"` and `'` (the title delimiters that aren't already in the inline set; `(` and `)` are), so the caller can choose any of the three title-quote styles safely.

Pick the right helper for the slot — markdown title and HTML attribute share the look but use different escape rules:

Example
1[text](url "X")              → LinkTitle      (markdown title)
2<a title="X">                → HTMLEscape     (HTML attribute)
3<h5>X</h5>                   → HTMLEscape     (HTML element body)

Swapping HTMLEscape for LinkTitle is wrong: HTML's `&` written inside a markdown title renders as the literal characters `&`. Swapping LinkTitle for HTMLEscape is wrong: markdown's `\"` survives into the rendered HTML as a literal backslash-quote.

Not idempotent (see package doc).

func NestedPrefix

1func NestedPrefix(s string) string
source

NestedPrefix validates a prefix string for line-prefixing builders like md.Nested, which prepends `prefix` to every line of content to render the content as a nested/indented sub-block.

Allowed: any string matching `^[ \t>]*$` — spaces, tabs, blockquote `>` chars only. Anything else (a `#`, a `-`, a letter) would let a caller turn benign sub-content into a heading, list, or paragraph at the wrong nesting level.

Returns s if valid, "" otherwise. Strips bidi/zero-width first — otherwise an invisible character hidden inside a `>` prefix would be replicated on every nested content line, producing per-line display-vs-storage divergence.

On "" return, fall back to a known-safe prefix literal (e.g. `"> "`) or skip the nesting entirely. Do not emit the raw user-supplied prefix.

func NormalizeBreaks

1func NormalizeBreaks(s string) string
source

NormalizeBreaks unifies CR-LF and lone CR to LF (CommonMark §2.2 line endings only — does NOT touch U+2028/U+2029). Use it when comparing or hashing user input that may have been authored on different platforms (Windows CRLF vs. Unix LF), so equivalent strings normalize to the same bytes. Idempotent.

Thin wrapper over chain/markdown.NormalizeBreaks.

func StripBidiAndZeroWidth

1func StripBidiAndZeroWidth(s string) string
source

StripBidiAndZeroWidth removes Unicode bidi controls and zero-width characters (U+200B-D, U+200E-F, U+202A-E, U+2066-9, U+FEFF) from s. Use it when storing or comparing user-supplied strings outside of a markdown context — for example, before saving a display name to state, or before hashing a search query. Idempotent: calling twice gives the same result.

Thin wrapper over chain/markdown.StripBidiAndZeroWidth.

func TableCell

1func TableCell(s string) string
source

TableCell prepares user content for a GFM table cell — the bytes between two `|` column delimiters in a table row like `| cell-a | cell-b | cell-c |`. An unescaped `|` inside cell content would open a new column, letting a malicious user shift every column to its right.

On top of InlineText's behavior, TableCell:

  • escapes `|` to `\|` so user content can't end the cell early.
  • replaces tabs with single spaces. CommonMark expands tabs to the next multiple-of-4 column boundary (variable 1-4 spaces), which would shift the displayed cell-content width unpredictably and confuse table alignment.

Not idempotent (see package doc).

func URL

1func URL(s string) string
source

URL validates a URL for use as a link href, percent-encodes unsafe bytes, and rejects anything outside the allowlist of schemes.

Allowlist:

  • http, https
  • mailto (rejected if it carries any query: prefill phishing via body, subject, cc, etc. Both '?' and '&' are rejected; see linkSchemeAllowed for why '&' counts.)
  • any URL WITHOUT a scheme — relative paths (`/path`, `./rel`, `bare-path`), query-only (`?q=v`), fragment-only (`#anchor`). A `:` appearing inside the URL (e.g. `/path:foo`, `?q=a:b`) is NOT a scheme separator per RFC 3986 — only `:` immediately after a leading `[a-zA-Z][a-zA-Z0-9+.-]*` counts.

Rejected (have an unknown scheme):

  • javascript:, data:, vbscript:, blob:, file:, etc.
  • `//host/...` (protocol-relative — tracking-pixel vector)

Returns "" if the URL is empty after trim or fails the allowlist.

func UserName

1func UserName(s string) string
source

UserName validates the r/sys/users-registration charset: ^[a-z][a-z0-9]*([_-][a-z0-9]+)*$ length ≤ 64.

The native MatchCharsetN enforces the leading-letter + tail-charset shape and length bound; this helper also performs the bidi-strip pre-pass. The "no consecutive [_-]" rule from r/sys/users is NOT enforced here (it's a registration-policy rule, not a sanitization concern — registrations go through r/sys/users itself).

Returns the (bidi-stripped) input if valid, "" otherwise. On a "" return, do not emit the user-mention markup at all (e.g. skip the `[@user](/u/user)` link); falling back to the raw user-supplied string would defeat the validation.

Imports 3

  • chain/markdown stdlib
  • html stdlib
  • strings stdlib

Source Files 3