1// Package sanitize provides input-cleaning primitives and safe-emit2// builders for each markdown lexical slot. Realm authors wrap user-3// supplied strings with these helpers before flowing them into rendered4// markdown output. Each helper targets one specific slot (link text,5// heading text, URL href, table cell, HTML attribute, fenced code block,6// blockquote, footnote definition, link-reference definition, etc.) and7// neutralizes the bytes that would otherwise let user content break out8// of that slot or inject new top-level structure.9//10// Pick the right helper from the table under "Picking the right helper"11// below, then wrap each user-supplied argument exactly once at the call12// site (see "The audit rule").13//14// # Wrap once15//16// Most escapers and safe-emit builders in this package are NOT17// idempotent — applying them twice re-escapes bytes the first pass18// added (`\*` becomes `\\\*`, `&` becomes `&amp;`, a fenced19// block gets re-fenced). Wrap each user-derived string with at most20// one sanitize.* call. Block and BlockRich are exceptions —21// idempotent by design — but the at-most-once rule is still the22// safest default. See the "Idempotence classes" enumeration below23// for the full breakdown.24//25// Some markdown-builder packages (e.g. p/moul/md) sanitize the args of26// specific helpers internally — see each builder's package doc for the27// per-helper contract. If the builder sanitizes for you, pass the raw28// user input; if it doesn't, wrap the input with the right sanitize.*29// helper at the call site.30//31// # Picking the right helper32//33// Match the helper to the slot the user content lands in:34//35// slot helper36// -------------------------------------------------------------37// [text](url) InlineText (text)38// # Heading text InlineText39// **bold** _italic_ InlineText40//  InlineText (alt)41// > [!NOTE] one-line title InlineText42// multi-paragraph post body Block43// multi-paragraph post body w/ rich block BlockRich44// structure (headings, lists, tables, etc.)45// multi-line blockquote (`> ` prefixed) Blockquote46// multi-line blockquote w/ rich block body BlockquoteRich47// [text](url "title") LinkTitle (title)48// | cell | TableCell49// <gno-card caption="X"> HTMLEscape50// <h5>X</h5> HTMLEscape51// any URL going into ](X) URL52// any image src going into (X) ImageURL53// `inline code` inside running prose InlineCode54// multi-line fenced code block CodeBlock55// multi-line fenced code with language tag LanguageCodeBlock56// [^name]: footnote body FootnoteDefinition57// [label]: url "title" reference def LinkReferenceDefinition58// r/sys/users handle UserName (validator)59// g1.../gpub1... etc. BechString (validator)60// footnote / LRD label / {#id} anchor name FootnoteLabel (validator)61// fenced-code language tag LanguageName (validator)62// prefix arg to md.Nested NestedPrefix (validator)63//64// # Invariants65//66// All helpers in this package are panic-free for any string input and67// run in O(len(input)) time with bounded allocation.68//69// Idempotence classes:70//71// Idempotent (calling twice == calling once):72// StripBidiAndZeroWidth, NormalizeBreaks73// UserName, BechString, FootnoteLabel, LanguageName, NestedPrefix74// URL, ImageURL (accept→identity; reject→"")75// Block (bracket walker treats \[/\] as ordinary;76// line-leader escapes don't re-fire on77// already-escaped `\#` etc.)78// BlockRich (TrimLeft/TrimRight + "\n\n" wrap is stable)79//80// NOT idempotent — never wrap an already-sanitized string:81// InlineText, LinkTitle, TableCell (re-escape backslashes)82// HTMLEscape (re-escapes `&` → `&`)83// Blockquote, BlockquoteRich (re-prefixes `> `, nesting the quote each pass)84// InlineCode, CodeBlock,85// LanguageCodeBlock (wrap with a fence — calling twice double-wraps)86// FootnoteDefinition,87// LinkReferenceDefinition (compose Block/InlineText/URL internally —88// passing already-sanitized strings double-escapes)89//90// CodeFence is pure: same inputs always give the same output.91//92// Validators (UserName / BechString / FootnoteLabel / LanguageName /93// NestedPrefix) return either the cleaned input verbatim or "". They94// never partially-sanitize: if the input doesn't match the slot's95// charset/shape, the answer is rejection.96//97// # Composition rules98//99// Direct sanitize use (when emitting markdown without a builder package):100//101// out := "# " + sanitize.InlineText(userTitle) + "\n\n" +102// sanitize.Block(userBody)103// out += sanitize.Blockquote(userQuote)104// out += sanitize.LanguageCodeBlock(realmLang, userCode)105//106// Use with a builder package (e.g. p/moul/md): pass raw user input to107// the builder helpers that sanitize internally — do NOT pre-wrap with108// sanitize.*, or the input gets double-escaped (escapers are not109// idempotent). See the builder's package doc for the per-helper110// contract. For example, with p/moul/md:111//112// md.Blockquote(userProse) // good — md.Blockquote sanitizes113// md.LanguageCodeBlock(realmLang, userCode) // good — sanitizes both args114// md.Link(userText, userURL) // good — sanitizes both slots115//116// md.Blockquote(sanitize.Block(userProse)) // BAD: double-wrap117// md.Link(sanitize.InlineText(t), sanitize.URL(u)) // BAD: double-wrap118//119// Wrong (across all callers):120//121// sanitize.InlineText(sanitize.InlineText(s)) double-wrap (re-escape)122// sanitize.TableCell(sanitize.InlineText(s)) TableCell already calls InlineText123// sanitize.URL(sanitize.InlineText(href)) inline-escape backslash-escapes `.` `-` `_`124// inside the URL, corrupting the host/path125// sanitize.Blockquote(sanitize.Blockquote(s)) double-wrap — outer would escape the126// inner `> ` prefixes127// sanitize.Block(sanitize.BlockRich(s)) double-sanitize — strict Block re-escapes128// the markers BlockRich preserved (headings,129// lists, tables); BlockRich's rich structure130// renders as literal text after Block escapes131// its line-leaders132// sanitize.BlockRich(sanitize.Block(s)) pointless double-sanitize — Block already133// escaped every line-leader to `\#`/`\>`/etc.;134// BlockRich preserves the backslash escapes135// as visible artifacts in user prose136// sanitize.Blockquote(sanitize.BlockRich(s)) double-sanitize — Blockquote's Block step137// re-escapes the markers BlockRich preserved138// sanitize.BlockRich(sanitize.Blockquote(s)) nonsense — Blockquote already line-prefixed139// with `> `; BlockRich expects raw user content140// sanitize.BlockquoteRich(sanitize.BlockRich(s)) double-wrap — Rich + Rich nests twice141// sanitize.BlockRich(sanitize.TableCell(s)) wrong slot — use TableCell for cell content,142// BlockRich for multi-paragraph block content143// sanitize.TableCell(multiParagraphProse) newlines fold to space silently; use a144// non-table layout for multi-paragraph text145//146// # Threat model147//148// Sanitizers in this package defend against:149//150// - bidi/zero-width injection: invisible characters that make151// displayed text disagree with stored bytes (e.g. an address `g1abc...`152// that renders as `g1xyz...`, or a username that visually collides153// with another). Stripped by StripBidiAndZeroWidth, which runs as154// the first step of every text-shaped helper.155// - line-ending homoglyphs: CR-only and Unicode separators156// (U+0085 NEL, U+2028, U+2029) that some renderers treat as line157// breaks. Folded uniformly.158// - markdown-structure injection: user content opening a heading,159// blockquote, list, code fence, link-reference def, setext underline,160// gnoweb extension delimiter, or GFM table row at document level.161// Strict Block escapes the line-leading `|` of any GFM table row so162// user content cannot inject `<table>`-shaped structure; permissive163// BlockRich preserves table rows so authors can compose `<table>`164// elements (gnoweb loads extension.Table per render_config.go).165// - HTML block type 1-5 absorption: CommonMark §4.6 HTML block types 1166// (`<script>`, `<pre>`, `<style>`, `<textarea>`), 2 (`<!--`), 3167// (`<?`), 4 (`<!UPPER`), and 5 (`<![CDATA[`) do NOT close on a blank168// line — they only close on a type-specific token (`</tag>`, `-->`,169// `?>`, `>`, `]]>`) or EOF. Without a defense, user content opening170// any of these would swallow realm chrome appended afterward. Both171// Block and BlockRich line-escape the openers (prepend `\`) so the172// block never opens; this defense is unconditional in both modes.173// Types 6 and 7 close on a blank line, so BlockRich's `\n\n`174// paragraph envelope already bounds them and no escape is needed.175// - realm-discipline boundary (caller's responsibility, not enforced):176// callers should emit realm chrome at flush-left column 0 around177// `BlockRich(user)`. Indented chrome (4+ leading spaces, list-item178// continuations, footnote-definition body, or an unclosed Type 1179// HTML tag in realm chrome before the call) can extend across blank180// lines into user content or vice versa. The sanitizer cannot181// defend against malformed realm chrome — only against user input.182// - footnote / link-reference namespace pollution: user content183// containing `[^name]` or `[text][label]` syntax that would otherwise184// resolve against realm-defined footnote definitions or link185// reference definitions elsewhere on the page. Block escapes the186// opening `[` in both shapes.187// - reference-link / footnote-ref / shortcut-ref collisions:188// `[text][label]`, `[^name]`, and bare `[label]` shortcut forms189// are ALL neutralized by Block's bracket walk, which preserves190// only inline `[text](url)` and `` syntax — everything191// else has both `[` and `]` backslash-escaped, so the parser sees192// literal text and can't resolve against realm-defined LRDs or193// footnote definitions.194// - multi-line LRD evasion: Block's walker recognises `[lab\nel]: url`195// across newlines (single `\n` OK, blank line aborts) and strips196// the whole region. `\]` inside the label is honored as an escaped197// literal, so `[label\]: url` is NOT treated as an LRD (renders as198// literal text).199// - URL scheme abuse: javascript:, data:text/html, vbscript:, blob:,200// protocol-relative //, mailto: with prefill phishing parameters.201// Allowlist-only (URL / ImageURL).202// - HTML attribute / element breakout: `"`, `<`, `>`, `&`, `'` inside203// HTML lexical slots. Handled by HTMLEscape.204// - CommonMark §2.3 NUL: replaced with U+FFFD by Block, InlineText,205// LinkTitle, TableCell, HTMLEscape, InlineCode, CodeBlock, and206// LanguageCodeBlock.207// - code-fence leakage: a user-opened ``` ``` ``` fence that runs to EOF208// with no closing fence, which would otherwise swallow every realm-209// emitted line that follows. Block auto-closes any open fence at EOF.210// - table-alignment drift: tabs inside table cells expanding to variable211// widths (1-4 spaces depending on column position) and shifting cell212// boundaries unpredictably. TableCell replaces tabs with single spaces.213//214// What this package does NOT do:215//216// - It does not store state. Every helper is a pure function.217// - It does not validate semantic correctness. sanitize.URL accepts218// a syntactically valid https:// URL even if the host is malicious;219// URL reputation is a separate layer.220// - It does not enforce CSS containment. ImageURL admits data:image/*221// URIs on the assumption that the deploying gnoweb instance caps222// rendered image dimensions via CSS. Without that cap, a malicious223// image can blow out the page layout or exhaust memory.224// - It does not perform structural sandboxing of foreign markdown.225// If a realm concatenates an opaque markdown blob returned from a226// polymorphic interface (`someThing.Render()`), it needs a structural227// sandbox primitive (e.g. a `<gno-card>` extension), not just leaf228// sanitization.229//230// # When to use Block vs BlockRich231//232// Both are safe sanitizers; both run identical realm-binding defenses.233// They differ in what user-authored block structure survives:234//235// - Block — paragraph-shaped only. Escapes `#`, `>`, list markers,236// `---`/`***`/`___` thematic breaks, and `===`/`---` setext237// underlines. Use for leaf slots — footnote definition bodies,238// table cells, blockquote bodies (Blockquote uses Block), single-239// paragraph prose, any slot where richer structure has no benefit240// or where richer structure could visually impersonate realm chrome.241//242// - BlockRich — full-richness. Preserves user-authored headings,243// lists, quotes, HR, setext. Use for user content the realm intends244// to compose with full block-level structure, typically inside a245// sandbox container (`<gno-card>`, `<gno-foreign>`) or a CSS-demoted246// region. BlockRich's qualifying-setext defense prevents the247// cross-boundary attack (user content reaching back to promote248// realm chrome to a heading), but inner-heading visual containment249// is the realm's CSS responsibility. gnoweb does not yet ship CSS250// rules that demote headings inside sandbox containers — until they251// land, BlockRich + sandbox renders inner headings at literal size.252//253// Do NOT compose Block and BlockRich in either direction. Pick one254// helper at the right level.255//256// # Extending257//258// A new helper added to this package MUST:259//260// 1. Be panic-free for any string input.261// 2. Strip bidi+zero-width before any other transform (so display262// equals storage end-to-end).263// 3. Declare its idempotence class in the table above.264// 4. Document the markdown / HTML lexical slot it targets.265// 5. Reject rather than partially-sanitize when input is structurally266// invalid (return "" — never half-process an address or URL).267// 6. Pick exactly one of the two return-value contracts and stick to268// it: escapers always return a transformed string and never reject269// (any input is OK — the transformation makes it safe); validators270// return the cleaned input verbatim on accept or "" on reject and271// never half-process. Mixing the contracts within one helper is a272// bug — callers can't reason about whether "" means "input was273// already empty" or "input was rejected".274package sanitize275276import (277 "chain/markdown"278 "html"279 "strings"280)281282// ----- Re-exports of the public chain/markdown natives -----283//284// These are general-purpose data-hygiene primitives, not markdown-specific.285// The other helpers in this package call them internally, so realms emitting286// markdown rarely need to call them directly. Reach for these when you have287// a non-markdown use case — e.g. normalizing a username before storage,288// canonicalizing a search query, or stripping invisible characters from289// any user string that will be displayed or compared.290291// StripBidiAndZeroWidth removes Unicode bidi controls and zero-width292// characters (U+200B-D, U+200E-F, U+202A-E, U+2066-9, U+FEFF) from s.293// Use it when storing or comparing user-supplied strings outside of a294// markdown context — for example, before saving a display name to state,295// or before hashing a search query. Idempotent: calling twice gives the296// same result.297//298// Thin wrapper over chain/markdown.StripBidiAndZeroWidth.299func StripBidiAndZeroWidth(s string) string {300 return markdown.StripBidiAndZeroWidth(s)301}302303// NormalizeBreaks unifies CR-LF and lone CR to LF (CommonMark §2.2 line304// endings only — does NOT touch U+2028/U+2029). Use it when comparing305// or hashing user input that may have been authored on different306// platforms (Windows CRLF vs. Unix LF), so equivalent strings normalize307// to the same bytes. Idempotent.308//309// Thin wrapper over chain/markdown.NormalizeBreaks.310func NormalizeBreaks(s string) string {311 return markdown.NormalizeBreaks(s)312}313314// ----- Escapers -----315316// InlineText prepares an arbitrary user string for an INLINE markdown317// slot — anywhere the rendered output stays on a single line and lives318// inside a larger markdown construct.319//320// Use for:321// - link text: [InlineText(label)](url)322// - heading text: # InlineText(title)323// - bold/italic body: **InlineText(name)**324// - image alt text: 325// - single-line block-context slots:326// > [!NOTE] InlineText(title)327// > Author: InlineText(name)328//329// Multi-paragraph prose belongs in Block, not InlineText. InlineText330// folds every newline to a single space (so paragraph structure is331// erased) and escapes inline-active CommonMark punctuation:332//333// \ * _ [ ] ( ) ~ > - + . ! ` # < &334//335// Two characters are intentionally NOT escaped:336//337// - `|` — only meaningful in GFM table rows. Leaving it literal here338// lets TableCell (which calls InlineText then escapes `|` itself)339// avoid double-escaping pipes into `\\|`.340// - `=` — only meaningful as a setext heading underline, which is a341// line-level construct. Escaping `=` inline would mangle expressions342// like `x = 1` for no benefit.343//344// Not idempotent (see package doc).345func InlineText(s string) string {346 s = markdown.StripBidiAndZeroWidth(s)347 s = markdown.NormalizeBreaks(s) // CM §2.2 \r\n / \r → \n348 s = foldNewlinesAndSeparators(s, ' ') // \n + NEL + U+2028/U+2029 → space349 return markdown.EscapeInline(s)350}351352// Block prepares user content for a top-level BLOCK markdown context353// where paragraphs, line breaks, code blocks, and other block structure354// should survive — but where the content must NOT be able to inject355// new top-level constructs (headings, lists, blockquotes,356// link-reference definitions, setext underlines, gnoweb extension357// delimiters, GFM table rows).358//359// Output shape: every non-empty result begins AND ends with "\n\n" —360// CM §4.8 blank lines on both sides — so user content is guaranteed361// to occupy its own paragraph(s), isolated from any realm chrome that362// precedes OR follows it. This bounds CM §4.6 HTML block types 6 and363// 7 (`<div>`, `<table>`, `<form>`, arbitrary `<foo>` tags) which364// close on a blank line and are NOT escaped in any mode, and it365// defeats first-line setext promotion (`===`/`---`) that strict-mode366// escapes miss when the previous line is blank in the user input but367// non-blank in the concatenated realm output. Empty input (or input368// that strips entirely, e.g. a lone LRD) returns "" — no envelope is369// emitted.370//371// Use for any multi-paragraph user-supplied prose that the realm372// concatenates into its rendered output:373// - post bodies, comments, replies374// - profile bios, About sections375// - proposal descriptions, governance motions376// - changelog entries, release notes377//378// What Block does with each kind of attacker input:379//380// User attempt | Block's response381// ------------------------------------------------------|----------------------------------------------------382// --- preserved verbatim --- |383// [text](url) inline link,  image | preserved verbatim384// ------------------------------------------------------|----------------------------------------------------385// --- escaped / stripped / folded --- |386// # heading at line-start | escaped → literal `# heading`387// > quoted at line-start | escaped → literal `>`388// - item, * item, + item, 1. item at line-start | escaped389// ---, ***, ___ (3+) at line-start | escaped390// === or --- on its own line after non-blank text | escaped (no setext promotion of the line above)391// <gno-card>, <gno-columns>, any <gno-…>/</gno-…> at | escaped (wildcard match) → literal text392// line-start |393// | a | b | GFM table row (line-leading `|`) | escaped → literal `| a | b |`394// <!--, <script>, <pre>, <style>, <textarea>, <?…?>, | escaped (\<…) → literal text;395// <!DOCTYPE…>, <![CDATA[…]]> at line-start | blocks goldmark from opening a396// (CM §4.6 HTML block types 1-5) | blank-line-NON-terminating HTML block397// [text][realm-label] ref-link USE | both bracket pairs escaped → \[text\]\[realm-label\]398// [^name] footnote-ref | both brackets escaped → \[^name\]399// [label] bare shortcut-ref | both brackets escaped → \[label\]400// [label]: url link-reference definition | whole region stripped (incl. multi-line label401// (incl. [lab\nel]: url multi-line) | `[lab\nel]: url` and any title continuation)402// [label\]: url (backslash-escaped `]`) | NOT stripped; brackets escaped → paragraph text403// code fence opened without close | autoclosed at end of input404// NUL byte (\x00) | replaced with U+FFFD405// U+2028 / U+2029 / U+0085 (NEL) | folded to `\n`406// bidi/zero-width controls | stripped407//408// COMPOSITION GOTCHA: Block's EOF fence-autoclose appends a final409// fence line. If you wrap Block's output with a line-prefixing410// builder like md.Blockquote (which prepends `> ` per line) or411// md.Nested, that closing fence becomes a prefixed line. The output412// is still safe (the fence still closes correctly) but may render413// awkwardly. If pixel-perfect output matters, strip a trailing blank414// fence line after Block.415//416// Why backslash and not a space for `<gno-…>` lines: gnoweb's417// extension parsers call `util.TrimLeftSpace` on the line before tag418// matching, which would strip a leading space and let the tag match419// anyway. A leading `\` survives the trim (only ASCII whitespace +420// form-feed are stripped) and is consumed by the inline escape phase421// before Type-7 HTML block detection can fire (Type-7 requires the422// first non-whitespace char to be `<`).423//424// Inline emphasis, code spans, inline links, and soft line breaks425// within a paragraph are PRESERVED — users can format. Pipes that426// are NOT at line-start stay literal so prose can still write things427// like `a | b`.428//429// Idempotent: Block(Block(s)) is byte-identical to Block(s). The430// bracket walker strips LRDs on the first pass; remaining `[`/`]`431// outside inline-link/image spans are escaped to `\[`/`\]`, and432// already-escaped brackets are preserved on subsequent passes433// (pass-2 backslash-parity tracking). Still, wrap each user-supplied434// string exactly once — chained sanitization adds no value and435// burns gas.436func Block(s string) string {437 s = markdown.NormalizeBreaks(s)438 s = markdown.StripBidiAndZeroWidth(s)439 s = replaceNULWithFFFD(s)440 s = markdown.EscapeBlockHazards(s)441 // Symmetric "\n\n" envelope — same pattern BlockRich uses for the442 // same reasons (see BlockRich docstring "Cross-paragraph safety").443 // Strict mode escapes most line-leading hazards (setext, GFM table444 // row, CM §4.6 HTML types 1-5, list/heading/HR markers), but two445 // hazards remain that only a blank-line break can close:446 //447 // - CM §4.6 HTML block types 6 and 7 (`<div>`, `<table>`,448 // `<form>`, arbitrary `<foo>` tags) are NOT escaped in any mode449 // — they close on a blank line per CM. Without a trailing450 // "\n\n", a `<div>` at the end of user content extends into451 // appended realm chrome.452 //453 // - First-line setext: strict mode's setext escape only fires454 // when the previous line is non-blank IN THE USER'S INPUT.455 // A user whose first line is `===` slips past, and concatenated456 // after `chrome\n` would promote chrome to H1. The leading457 // "\n\n" forces a paragraph break so chrome cannot be merged.458 //459 // TrimLeft/TrimRight + fixed wrap is idempotent: Block(Block(s)) is460 // byte-identical to Block(s). Empty post-escape result short-461 // circuits to "" so realm concatenation doesn't leak stray blank462 // lines for trivially empty inputs (e.g. lone LRD that strips463 // entirely).464 s = strings.TrimLeft(s, "\n")465 s = strings.TrimRight(s, "\n")466 if s == "" {467 return ""468 }469 return "\n\n" + s + "\n\n"470}471472// BlockRich is the permissive counterpart of Block. Both are safe473// sanitizers — the distinction is what markdown structure survives:474//475// - Block escapes line-leading block markers (`#`, `>`, `-`, `*`,476// `+`, `1.`), thematic breaks (`---`/`***`/`___`), and setext477// underlines (`===`/`---`). User content becomes paragraph-shaped.478// - BlockRich PRESERVES all of those, so user content can compose479// headings, lists, quotes, horizontal rules, and setext-styled480// headings. Realm-binding defenses stay on (extension delimiters481// `<gno-…>`, the bracket walker for link / LRD / ref /482// footnote / shortcut, fence autoclose, NUL / bidi /483// Unicode-separator folding). GFM table-row openers are484// PRESERVED (see "Tables" below).485//486// Cross-paragraph safety: BlockRich's output begins with "\n\n"487// AND ends with "\n\n" — CM §4.8 blank lines on both sides — so488// user content is guaranteed to occupy its own paragraph(s),489// isolated from anything the realm emits before OR after. Symmetric490// isolation closes four distinct attacks:491//492// - Cross-paragraph setext promotion (backward). User content493// `body\n===\nmore` concatenated after realm chrome (no494// trailing `\n`) would, without paragraph isolation, place495// "chrome\nbody" in one paragraph; the `===` setext underline496// would then promote that merged paragraph to H1, hijacking497// realm chrome. The leading "\n\n" forces a paragraph break.498//499// - Cross-paragraph GFM table promotion (backward). User content500// beginning with `|---|---|` (a table delimiter row) would,501// without a blank-line break, retroactively turn the preceding502// realm line into a `<thead>`. Paragraph isolation prevents503// the table-detection scan from crossing the boundary.504//505// - Cross-paragraph GFM table promotion (forward). Realm chrome506// appended immediately after BlockRich(user) that begins with507// `|---|` would, without a trailing blank line, extend user's508// last line into a table header and pull realm chrome into the509// body row. The trailing "\n\n" prevents the merge.510//511// - Lazy paragraph continuation (forward). Paragraph-shaped realm512// chrome appended immediately after BlockRich(user) would, via513// CM §5.2, merge into user's trailing paragraph and inherit any514// block-level decoration it carries.515//516// First-line qualifying-setext escape (the517// `neuterLeadingSetextIfQualifying` pre-pass) remains in place as518// belt-and-suspenders: if the first non-blank line of user input519// matches the CM §4.3 setext-underline pattern (run of `=` or `-`520// with 0-3 leading spaces and only trailing whitespace), BlockRich521// inserts `\` before the first `=`/`-`. This is redundant given522// paragraph isolation but harmless and inexpensive.523//524// # Tables525//526// BlockRich preserves line-leading `|` so user content can527// compose GFM tables:528//529// | Header A | Header B |530// |----------|----------|531// | cell a | cell b |532//533// renders as a real `<table>` element. Strict Block continues to534// escape line-leading `|` (each row becomes literal `\| a | b |`535// text). When the realm authors the table itself and inserts user536// content into a specific cell, use TableCell — NOT BlockRich —537// to sanitize that cell value.538//539// What attacker input produces what (full table, same rows as Block540// except where marked CHANGED):541//542// User attempt | BlockRich response543// ----------------------------------------------|--------------------------------------------------544// --- preserved (compose freely) --- |545// # heading at line-start | preserved [CHANGED from Block]546// > quoted at line-start | preserved [CHANGED]547// - item, * item, + item, 1. item | preserved [CHANGED]548// ---, ***, ___ thematic break | preserved [CHANGED]549// === or --- setext underline | preserved when preceded by user text;550// | escaped (\===/\---) if the first non-blank551// | line of input [CHANGED]552// | a | b | GFM table row (line-leading |) | preserved → renders as <table> when followed by553// | a delimiter row [CHANGED]554// [text](url),  | preserved verbatim [SAME]555// ----------------------------------------------|--------------------------------------------------556// --- escaped / stripped / folded --- |557// <gno-card>, any <gno-…>/</gno-…> at line-start| escaped (wildcard match) [SAME]558// <!--, <script>, <pre>, <style>, <textarea>, | escaped (\<…) [SAME] — Types 1-5 don't close559// <?…?>, <!DOCTYPE…>, <![CDATA[…]]> | on blank lines, so `\n\n` envelope560// at line-start (CM §4.6 HTML block types 1-5)| doesn't isolate them; explicit escape561// [text][realm-label] ref-link USE | both pairs escaped [SAME]562// [^name] footnote-ref | both brackets escaped [SAME]563// [label] bare shortcut-ref | both brackets escaped [SAME]564// [label]: url link-reference definition | whole region stripped [SAME]565// [label\]: url (escaped `]`) | not stripped; brackets escaped [SAME]566// code fence opened without close | autoclosed at end of input [SAME]567// NUL byte (\x00) | replaced with U+FFFD [SAME]568// U+2028 / U+2029 / U+0085 (NEL) | folded to `\n` [SAME]569// bidi/zero-width controls | stripped [SAME]570//571// Use BlockRich for user content the realm intends to compose with572// full block-level richness — typically inside a sandbox container573// (`<gno-card>`, `<gno-foreign>`) or a CSS-demoted region where inner574// headings render visually distinct from realm chrome. The realm575// must own the visual containment: concatenating BlockRich's output576// directly into a top-level page still lets the user write `# heading`577// at document level. BlockRich's cross-boundary setext defense prevents578// the worst case (reaching backwards into realm bytes), but visual579// containment of inner headings is the realm's CSS responsibility.580// gnoweb does not yet ship CSS rules that demote inner headings inside581// `<gno-card>` / `<gno-foreign>` — until those rules land, realms582// using BlockRich + a sandbox should be aware that inner headings583// render at their literal level.584//585// Idempotent: BlockRich(BlockRich(s)) is byte-identical to586// BlockRich(s). The TrimLeft-then-"\n\n"-prepend pattern strips587// any leading newlines and reapplies exactly two, so the leading588// shape is stable across passes; the qualifying-setext escape is589// stable (a line beginning with `\` no longer matches the setext590// pattern); and the bracket walker treats already-escaped591// `\[`/`\]` as ordinary bytes. Empty input (or input that strips592// to empty, e.g. a lone link-reference definition) returns "" —593// realm concatenation doesn't get a stray blank line.594// Still, wrap each user-supplied string exactly once — chained595// sanitization adds no value and burns gas.596//597// Realm-discipline boundary: BlockRich defends user input against598// every cross-paragraph attack listed above, but it CANNOT defend599// against malformed REALM chrome. Specifically, callers should emit600// realm chrome at flush-left column 0 around `BlockRich(user)`. If601// the realm chrome BEFORE the call contains an unclosed CM §4.6602// Type 1 HTML tag (`<script>`, `<pre>`, `<style>`, `<textarea>`),603// the `\n\n` envelope does NOT close it (Type 1 closes only on the604// matching close tag), and user-controlled `</tag>` content can605// then prematurely terminate it. Indented chrome (4+ leading606// spaces, list-item continuations, footnote-definition body) can607// likewise extend across the envelope into user content. Keep608// chrome flush-left and Type 1 tags closed within the chrome.609//610// PREVIEW: BlockquoteRich is currently the only in-tree caller of611// BlockRich; the API and the `"\n\n"` output shape may evolve once612// direct callers emerge.613func BlockRich(s string) string {614 s = markdown.NormalizeBreaks(s)615 s = markdown.StripBidiAndZeroWidth(s)616 s = replaceNULWithFFFD(s)617 // Fold Unicode separators (U+2028, U+2029, U+0085 NEL) to '\n'618 // BEFORE the setext-qualifying check. The native619 // EscapeBlockHazardsRich also folds them internally, but the Gno620 // helper below needs to see the folded form to correctly identify621 // the first non-blank line — otherwise an attacker can hide the622 // `===` setext underline behind a U+2028 / U+2029 / U+0085 and623 // reach back to promote realm chrome above it.624 s = foldSeparatorsToNewline(s)625 s = neuterLeadingSetextIfQualifying(s)626 s = markdown.EscapeBlockHazardsRich(s)627 // Ensure the output BOTH starts AND ends with "\n\n" — CM §4.8628 // blank lines on each side — so user content is GUARANTEED to629 // occupy its own paragraph(s), isolated from anything the realm630 // emits before OR after. Symmetric isolation closes four attacks:631 //632 // Backward (closed by leading "\n\n"):633 // 1. Deeper-setext: user content `body\n===\nmore` concatenated634 // after realm chrome (no trailing `\n`) would otherwise place635 // "chrome\nbody" in one paragraph; the `===` setext underline636 // would then promote that merged paragraph to H1, hijacking637 // realm chrome.638 // 2. GFM table-row promotion: user content beginning with639 // `|---|---|` (a table delimiter row) would, without a blank-640 // line break, retroactively promote the preceding realm line641 // into a `<thead>` cell.642 //643 // Forward (closed by trailing "\n\n"):644 // 3. GFM table-row promotion in reverse: realm appending its own645 // chrome immediately after BlockRich(user), where chrome646 // starts with `|---|`, would extend user's last line into a647 // table header and pull realm chrome into the body row.648 // 4. Lazy paragraph continuation: realm appending paragraph-649 // shaped chrome immediately after BlockRich(user) would, via650 // CM §5.2 lazy-continuation, merge into user's trailing651 // paragraph and inherit any block-level decoration it carries.652 //653 // `neuterLeadingSetextIfQualifying` above is now belt-and-654 // suspenders for the first-line setext case: even if the blank-655 // line guarantee were somehow defeated by an exotic CM consumer,656 // the first-line escape still blocks the simplest setext shape.657 //658 // Empty post-escape result short-circuits to "" so realm659 // concatenation doesn't leak stray blank lines for trivially empty660 // inputs (e.g. a lone link-reference definition that strips661 // entirely).662 //663 // Idempotency: TrimLeft and TrimRight strip ALL leading/trailing664 // "\n"s, then the wrap adds exactly two on each side. Stable665 // across passes.666 s = strings.TrimLeft(s, "\n")667 s = strings.TrimRight(s, "\n")668 if s == "" {669 return ""670 }671 return "\n\n" + s + "\n\n"672}673674// foldSeparatorsToNewline replaces U+0085 NEL (0xC2 0x85),675// U+2028 (0xE2 0x80 0xA8), and U+2029 (0xE2 0x80 0xA9) with '\n'.676// Leaves '\n' bytes alone. Used by BlockRich so the qualifying-setext677// pre-pass and the native both see the same line structure.678func foldSeparatorsToNewline(s string) string {679 // Cheap pre-check: only the 0xC2 / 0xE2 lead bytes can trigger.680 if !containsAnyByteForFold(s) {681 return s682 }683 out := make([]byte, 0, len(s))684 for i := 0; i < len(s); {685 c := s[i]686 if c == 0xC2 && i+1 < len(s) && s[i+1] == 0x85 {687 out = append(out, '\n')688 i += 2689 continue690 }691 if c == 0xE2 && i+2 < len(s) && s[i+1] == 0x80 && (s[i+2] == 0xA8 || s[i+2] == 0xA9) {692 out = append(out, '\n')693 i += 3694 continue695 }696 out = append(out, c)697 i++698 }699 return string(out)700}701702func containsAnyByteForFold(s string) bool {703 for i := 0; i < len(s); i++ {704 if s[i] == 0xC2 || s[i] == 0xE2 {705 return true706 }707 }708 return false709}710711// neuterLeadingSetextIfQualifying scans s for the first non-blank712// line. If that line matches the CommonMark §4.3 setext-underline713// pattern (0-3 leading spaces, then a run of all `=` or all `-`,714// then optional trailing whitespace, then `\n` or EOF), the function715// returns s with a `\` inserted before the first `=`/`-`. Otherwise716// returns s unchanged. The escape prevents a realm-emitted line above717// BlockRich's output from being retroactively promoted to a heading.718func neuterLeadingSetextIfQualifying(s string) string {719 pos := 0720 for pos < len(s) {721 // Walk to the first non-whitespace byte of the current line.722 lineStart := pos723 i := pos724 for i < len(s) && (s[i] == ' ' || s[i] == '\t') {725 i++726 }727 if i >= len(s) || s[i] == '\n' {728 // Blank line; advance to next line.729 if i >= len(s) {730 return s731 }732 pos = i + 1733 continue734 }735 // First non-blank line. Check setext-underline shape.736 if i-lineStart > 3 {737 return s // 4+ leading spaces = indented code, not setext738 }739 c := s[i]740 if c != '=' && c != '-' {741 return s // not a setext underline candidate742 }743 j := i + 1744 for j < len(s) && s[j] == c {745 j++746 }747 for j < len(s) && (s[j] == ' ' || s[j] == '\t') {748 j++749 }750 if j < len(s) && s[j] != '\n' {751 return s // mixed content on the line — not setext752 }753 return s[:i] + "\\" + s[i:]754 }755 return s756}757758// Blockquote wraps user content as a CommonMark blockquote: each line759// of the cleaned content gets a "> " prefix so the renderer displays760// it inside a `<blockquote>` element.761//762// Use for any multi-paragraph user-supplied text that the realm wants763// to render as a quotation: cited posts, attached responses, error764// snapshots that should visually stand out.765//766// The content is first cleaned by Block (bidi-strip, line-ending767// normalize, NUL→U+FFFD, bracket walker for link/image/LRD spans,768// block-marker escape, code-fence auto-close at EOF, Unicode-separator769// fold). Block's "\n\n" cross-paragraph envelope is then stripped —770// the `> ` marker creates the container boundary, so the envelope771// would only line-prefix to empty `> ` lines top and bottom — and772// every remaining line is prefixed with "> ". The user content can773// still use inline emphasis, code spans, and nested fenced code blocks774// inside the quote; what it cannot do is open new top-level structure775// (heading, list, blockquote, GFM table row, etc.) or escape the776// quote.777//778// Output shape — every non-empty result begins with "\n" and ends779// with "\n\n" (same shape as BlockquoteRich):780//781// - Leading "\n" guarantees a clean blockquote opener even when the782// realm concatenates `chrome + Blockquote(user)` without its own783// newline separator.784// - Trailing "\n\n" (blank line) cleanly ends the blockquote so a785// realm appending `Blockquote(user) + chrome` cannot pull chrome786// bytes into the quote via CommonMark §5.2 lazy continuation.787//788// Empty input (or input that strips entirely, e.g. a lone LRD)789// returns "" — no blockquote is emitted.790//791// Composition gotcha: Block's EOF code-fence auto-close (added when792// user content opens a ``` ``` ``` fence without closing it) becomes a793// "> ```" line at the end of the blockquote. Goldmark parses this794// correctly as the close of a fenced block inside the quote — the795// output is structurally safe — but the markdown source looks unusual796// to a human reviewer. If aesthetic output matters, ensure user797// content closes its own fences.798//799// Not idempotent (see package doc): wraps with `> ` per line, so800// calling twice double-wraps and the outer call's Block step escapes801// the inner `>` prefixes.802//803// Do NOT compose with BlockRich in either direction:804// - Blockquote(BlockRich(s)) double-sanitizes: BlockRich preserves805// `#`/`>`/etc., then Blockquote's Block step escapes them again.806// - BlockRich(Blockquote(s)) doesn't make sense: Blockquote already807// line-prefixed with `> `; BlockRich expects raw user content.808//809// For a quoted body that can contain headings, lists, nested quotes,810// or thematic breaks, use BlockquoteRich.811func Blockquote(text string) string {812 text = Block(text)813 // Block wraps its output with "\n\n" on each side for cross-814 // paragraph isolation. Inside a blockquote both wraps are redundant815 // — the `> ` marker creates the container boundary — and they816 // would line-prefix to two useless `> ` empty quoted lines top and817 // bottom. Strip ALL leading and trailing "\n"s so the body starts818 // and ends clean; this helper re-wraps with `\n` + body + `\n\n`819 // below (same shape as BlockquoteRich).820 text = strings.TrimLeft(text, "\n")821 if text == "" {822 return ""823 }824 text = strings.TrimRight(text, "\n")825 if text == "" {826 return ""827 }828 var sb strings.Builder829 sb.WriteByte('\n')830 for _, line := range strings.Split(text, "\n") {831 sb.WriteString("> ")832 sb.WriteString(line)833 sb.WriteByte('\n')834 }835 sb.WriteByte('\n')836 return sb.String()837}838839// BlockquoteRich is the permissive counterpart of Blockquote. Both840// wrap user content as a CommonMark blockquote (each line prefixed841// with `> `), but they differ in what block-level structure inside842// the quote survives:843//844// - Blockquote escapes line-leading block markers, so the quoted845// body is paragraph-shaped — `# x` inside a Blockquote stays a846// literal `#`.847// - BlockquoteRich PRESERVES line-leading block markers, so the848// quoted body can compose ATX headings, lists, thematic breaks,849// nested blockquotes (`> > nested`), and other block-level850// structure. Realm-binding defenses stay on (extension delimiters,851// GFM table-row openers, bracket walker, fence autoclose,852// NUL / bidi / Unicode-separator folding).853//854// Output shape — every non-empty result begins with "\n" and ends855// with "\n\n":856//857// - Leading "\n" guarantees a clean blockquote opener even when the858// realm concatenates `chrome + BlockquoteRich(user)` without its859// own newline separator. Without the leading "\n", chrome ending860// mid-line followed by "> quoted" would render `>` as literal861// paragraph text instead of opening a blockquote.862// - Trailing "\n\n" (blank line) cleanly ends the blockquote so a863// realm appending `BlockquoteRich(user) + chrome` cannot pull864// chrome bytes into the quote via CommonMark §5.2 lazy865// continuation. Without the trailing blank line, paragraph chrome866// immediately after BlockquoteRich would render inside the quote.867// - BlockRich's own leading "\n\n" (paragraph-isolation blank line)868// is stripped before line-prefixing — otherwise the output would869// carry one or two redundant empty `> ` quoted lines at the top.870// A single "\n" is then re-prepended at the BlockquoteRich871// boundary so `chrome + BlockquoteRich(user)` still lands the872// first `>` at column 0.873// - The cross-boundary setext defense BlockRich provides is874// redundant inside a blockquote: a setext underline inside `> `875// content can only promote a line in the same blockquote, never876// reach realm bytes (different CM container). BlockRich still877// applies it, harmlessly.878//879// What attacker input produces what (rows that differ from880// Blockquote are marked CHANGED):881//882// User attempt | BlockquoteRich response883// ----------------------------------------------|------------------------------------------------884// --- preserved inside `> ` quote --- |885// # heading | preserved as `> # heading` [CHANGED]886// > nested quote | preserved as `> > nested quote` [CHANGED]887// - item, * item, + item, 1. item | preserved as `> - item` etc. [CHANGED]888// ---, ***, ___ thematic break | preserved [CHANGED]889// === or --- setext underline | preserved when preceded by user text;890// | escaped (\===/\---) if first non-blank891// | line of input [CHANGED]892// | a | b | GFM table row (line-leading |) | preserved → renders as <table> inside the893// | blockquote when followed by a delimiter row [CHANGED]894// [text](url),  | preserved verbatim [SAME]895// ----------------------------------------------|------------------------------------------------896// --- escaped / stripped / folded --- |897// <gno-card>, any <gno-…>/</gno-…> at line-start| escaped (wildcard match) [SAME]898// <!--, <script>, <pre>, <style>, <textarea>, | escaped (\<…) [SAME] — CM §4.6 Types 1-5899// <?…?>, <!DOCTYPE…>, <![CDATA[…]]> | don't close on blank lines; without escape900// at line-start | they would swallow chrome past the `> ` quote901// [text][realm-label] ref-link USE | both pairs escaped [SAME]902// [^name] footnote-ref | both brackets escaped [SAME]903// [label] bare shortcut-ref | both brackets escaped [SAME]904// [label]: url link-reference definition | whole region stripped [SAME]905// code fence opened without close | autoclosed at end of input [SAME]906// NUL byte (\x00) | replaced with U+FFFD [SAME]907// U+2028 / U+2029 / U+0085 (NEL) | folded to `\n` [SAME]908// bidi/zero-width controls | stripped [SAME]909//910// Use BlockquoteRich when the realm wants to render user content as911// a quotation that itself reads like authored markdown — the visual912// CSS containment of `<blockquote>` already demotes inner headings913// relative to realm chrome, so the "inner headings need a sandbox"914// caveat that applies to BlockRich at top level does not apply here.915//916// Not idempotent: like Blockquote, calling twice double-wraps —917// `BlockquoteRich(BlockquoteRich(s))` produces `> > content`,918// nesting the quote a level deeper each pass.919//920// Empty input (or input that reduces to nothing after BlockRich,921// e.g. a lone link-reference definition) returns "" — no blockquote922// is emitted and neither the leading "\n" nor the trailing "\n\n"923// shape applies.924func BlockquoteRich(text string) string {925 text = BlockRich(text)926 // BlockRich wraps user content with "\n\n" on each side for927 // cross-paragraph isolation. Inside a blockquote both wraps are928 // redundant — the `> ` marker creates the container boundary —929 // and they would line-prefix to two useless `> ` empty quoted930 // lines top and bottom. Strip ALL leading and trailing "\n"s so931 // the body starts and ends clean; this helper re-wraps with `\n`932 // + body + `\n\n` below.933 text = strings.TrimLeft(text, "\n")934 if text == "" {935 return ""936 }937 // Strip ALL trailing newlines so the loop produces exactly one938 // `> line` per content line, then append `\n\n` at the end so the939 // blockquote terminates cleanly (see "Output shape" above).940 text = strings.TrimRight(text, "\n")941 if text == "" {942 return ""943 }944 var sb strings.Builder945 // Leading "\n" so `chrome + BlockquoteRich(user)` cannot land the946 // first `>` mid-line.947 sb.WriteByte('\n')948 for _, line := range strings.Split(text, "\n") {949 sb.WriteString("> ")950 sb.WriteString(line)951 sb.WriteByte('\n')952 }953 // Trailing blank line so `BlockquoteRich(user) + chrome` cannot954 // pull chrome into the quote via lazy continuation.955 sb.WriteByte('\n')956 return sb.String()957}958959// LinkTitle prepares user content for a CommonMark link-title or960// image-title slot — the optional quoted text after the URL in any of961// these forms:962//963// [text](url "TITLE")964// 965// [label]: url "TITLE"966//967// Escapes the inline-active set plus `"` and `'` (the title delimiters968// that aren't already in the inline set; `(` and `)` are), so the969// caller can choose any of the three title-quote styles safely.970//971// Pick the right helper for the slot — markdown title and HTML972// attribute share the look but use different escape rules:973//974// [text](url "X") → LinkTitle (markdown title)975// <a title="X"> → HTMLEscape (HTML attribute)976// <h5>X</h5> → HTMLEscape (HTML element body)977//978// Swapping HTMLEscape for LinkTitle is wrong: HTML's `&` written979// inside a markdown title renders as the literal characters `&`.980// Swapping LinkTitle for HTMLEscape is wrong: markdown's `\"` survives981// into the rendered HTML as a literal backslash-quote.982//983// Not idempotent (see package doc).984func LinkTitle(s string) string {985 s = markdown.StripBidiAndZeroWidth(s)986 s = markdown.NormalizeBreaks(s)987 s = foldNewlinesAndSeparators(s, ' ')988 return markdown.EscapeTitle(s)989}990991// TableCell prepares user content for a GFM table cell — the bytes992// between two `|` column delimiters in a table row like993// `| cell-a | cell-b | cell-c |`. An unescaped `|` inside cell994// content would open a new column, letting a malicious user shift995// every column to its right.996//997// On top of InlineText's behavior, TableCell:998// - escapes `|` to `\|` so user content can't end the cell early.999// - replaces tabs with single spaces. CommonMark expands tabs to1000// the next multiple-of-4 column boundary (variable 1-4 spaces),1001// which would shift the displayed cell-content width unpredictably1002// and confuse table alignment.1003//1004// Not idempotent (see package doc).1005func TableCell(s string) string {1006 s = InlineText(s)1007 s = strings.ReplaceAll(s, "\t", " ")1008 s = strings.ReplaceAll(s, "|", `\|`)1009 return s1010}10111012// HTMLEscape prepares user content for an HTML lexical slot inside1013// markdown — covers attribute values, element bodies, and HTML1014// comment bodies:1015//1016// <gno-card type="..." caption="X"> attribute value1017// <gno-alert title="X"> attribute value1018// <h5>X</h5> element body1019// <details><summary>X</summary>... element body1020// <!-- X --> comment body (safe: `>`1021// becomes `>`, so user1022// cannot inject `-->`)1023//1024// HTMLEscape escapes the union of attribute-breaking and body-breaking1025// characters (`<`, `>`, `&`, `"`, `'`), so one function safely serves1026// every HTML lexical context. Callers don't have to remember which1027// subset to use for which slot.1028//1029// Pick the right helper — markdown title and HTML attribute share1030// the look but use different escape rules:1031//1032// [text](url "X") → LinkTitle (markdown title)1033// <span title="X"> → HTMLEscape (HTML attribute)1034// <h5>X</h5> → HTMLEscape (HTML element body)1035//1036// Swapping InlineText for HTMLEscape is wrong: markdown's backslash1037// escapes survive into the rendered HTML as literal `\*`. Swapping1038// LinkTitle for HTMLEscape is also wrong: `&` written inside a1039// markdown title renders as the literal characters `&`.1040//1041// Not idempotent (see package doc): calling twice produces1042// `&` → `&amp;`.1043func HTMLEscape(s string) string {1044 s = markdown.StripBidiAndZeroWidth(s)1045 s = markdown.NormalizeBreaks(s)1046 s = foldNewlinesAndSeparators(s, ' ')1047 s = replaceNULWithFFFD(s)1048 return html.EscapeString(s)1049}10501051// ----- URL filters -----10521053// URL validates a URL for use as a link href, percent-encodes unsafe1054// bytes, and rejects anything outside the allowlist of schemes.1055//1056// Allowlist:1057// - http, https1058// - mailto (rejected if it carries any query: prefill phishing via1059// body, subject, cc, etc. Both '?' and '&' are rejected; see1060// linkSchemeAllowed for why '&' counts.)1061// - any URL WITHOUT a scheme — relative paths (`/path`, `./rel`,1062// `bare-path`), query-only (`?q=v`), fragment-only (`#anchor`).1063// A `:` appearing inside the URL (e.g. `/path:foo`, `?q=a:b`) is1064// NOT a scheme separator per RFC 3986 — only `:` immediately after1065// a leading `[a-zA-Z][a-zA-Z0-9+.-]*` counts.1066//1067// Rejected (have an unknown scheme):1068// - javascript:, data:, vbscript:, blob:, file:, etc.1069// - `//host/...` (protocol-relative — tracking-pixel vector)1070//1071// Returns "" if the URL is empty after trim or fails the allowlist.1072func URL(s string) string {1073 s = strings.TrimSpace(s)1074 if s == "" {1075 return ""1076 }1077 if !linkSchemeAllowed(s) {1078 return ""1079 }1080 return markdown.PercentEncodeURL(s)1081}10821083// ImageURL validates a URL for use as an image src. Kept separate from1084// URL — not a parameterized variant — because the allowlist shapes1085// differ qualitatively (data:image/* vs. mailto:) and a single boolean1086// flag would invite callers to pass the wrong default.1087//1088// Allowlist:1089// - http, https1090// - schemeless relative URLs starting with /, ./, or ..1091// (rejects // protocol-relative — tracking-pixel vector)1092// - data:image/svg+xml, data:image/png, data:image/jpeg,1093// data:image/gif, data:image/webp1094//1095// Any other data: subtype is rejected — data:text/html etc. would1096// render as inline HTML and execute embedded scripts.1097//1098// DEPLOYMENT PRECONDITION: data: URIs encode the bytes of the image1099// directly into the markup, so a malicious sender can construct an1100// image whose pixel dimensions are arbitrarily large at minimal byte1101// cost. The deploying gnoweb instance MUST clamp rendered image1102// dimensions via CSS (e.g. `max-width: 100%; max-height: <bound>`).1103// Without that cap, a single image can blow out the page layout or1104// exhaust the browser's memory.1105//1106// Returns "" if the URL is empty after trim or fails the allowlist.1107func ImageURL(s string) string {1108 s = strings.TrimSpace(s)1109 if s == "" {1110 return ""1111 }1112 if !imageSchemeAllowed(s) {1113 return ""1114 }1115 return markdown.PercentEncodeURL(s)1116}11171118// ----- Validators -----11191120// userNameCharsets builds the [2]uint64 bitmaps for the r/sys/users1121// charset: first [a-z], rest [a-z0-9_-]. Initialized once at package1122// init.1123var (1124 userNameFirstLo, userNameFirstHi uint641125 userNameRestLo, userNameRestHi uint641126 footnoteLabelFirstLo, footnoteLabelFirstHi uint641127 footnoteLabelRestLo, footnoteLabelRestHi uint641128 langFirstLo, langFirstHi uint641129 langRestLo, langRestHi uint641130 bechHrpFirstLo, bechHrpFirstHi uint641131 bechHrpRestLo, bechHrpRestHi uint641132 bechDataFirstLo, bechDataFirstHi uint641133 bechDataRestLo, bechDataRestHi uint641134)11351136func init() {1137 // UserName: first [a-z], rest [a-z0-9_-].1138 for c := byte('a'); c <= 'z'; c++ {1139 setBit(&userNameFirstLo, &userNameFirstHi, c)1140 setBit(&userNameRestLo, &userNameRestHi, c)1141 }1142 for c := byte('0'); c <= '9'; c++ {1143 setBit(&userNameRestLo, &userNameRestHi, c)1144 }1145 setBit(&userNameRestLo, &userNameRestHi, '_')1146 setBit(&userNameRestLo, &userNameRestHi, '-')11471148 // FootnoteLabel: [A-Za-z0-9_-] for both first and rest.1149 for c := byte('A'); c <= 'Z'; c++ {1150 setBit(&footnoteLabelFirstLo, &footnoteLabelFirstHi, c)1151 setBit(&footnoteLabelRestLo, &footnoteLabelRestHi, c)1152 }1153 for c := byte('a'); c <= 'z'; c++ {1154 setBit(&footnoteLabelFirstLo, &footnoteLabelFirstHi, c)1155 setBit(&footnoteLabelRestLo, &footnoteLabelRestHi, c)1156 }1157 for c := byte('0'); c <= '9'; c++ {1158 setBit(&footnoteLabelFirstLo, &footnoteLabelFirstHi, c)1159 setBit(&footnoteLabelRestLo, &footnoteLabelRestHi, c)1160 }1161 for _, c := range []byte{'_', '-'} {1162 setBit(&footnoteLabelFirstLo, &footnoteLabelFirstHi, c)1163 setBit(&footnoteLabelRestLo, &footnoteLabelRestHi, c)1164 }11651166 // LanguageName: [a-zA-Z0-9_+-] for both first and rest.1167 for c := byte('A'); c <= 'Z'; c++ {1168 setBit(&langFirstLo, &langFirstHi, c)1169 setBit(&langRestLo, &langRestHi, c)1170 }1171 for c := byte('a'); c <= 'z'; c++ {1172 setBit(&langFirstLo, &langFirstHi, c)1173 setBit(&langRestLo, &langRestHi, c)1174 }1175 for c := byte('0'); c <= '9'; c++ {1176 setBit(&langFirstLo, &langFirstHi, c)1177 setBit(&langRestLo, &langRestHi, c)1178 }1179 for _, c := range []byte{'_', '+', '-'} {1180 setBit(&langFirstLo, &langFirstHi, c)1181 setBit(&langRestLo, &langRestHi, c)1182 }11831184 // Bech HRP (when prefix==""): [a-z], 1-16 chars.1185 for c := byte('a'); c <= 'z'; c++ {1186 setBit(&bechHrpFirstLo, &bechHrpFirstHi, c)1187 setBit(&bechHrpRestLo, &bechHrpRestHi, c)1188 }11891190 // Bech data part: [a-z0-9], 6-90 chars.1191 for c := byte('a'); c <= 'z'; c++ {1192 setBit(&bechDataFirstLo, &bechDataFirstHi, c)1193 setBit(&bechDataRestLo, &bechDataRestHi, c)1194 }1195 for c := byte('0'); c <= '9'; c++ {1196 setBit(&bechDataFirstLo, &bechDataFirstHi, c)1197 setBit(&bechDataRestLo, &bechDataRestHi, c)1198 }1199}12001201func setBit(lo, hi *uint64, c byte) {1202 if c < 64 {1203 *lo |= 1 << c1204 } else {1205 *hi |= 1 << (c - 64)1206 }1207}12081209// UserName validates the r/sys/users-registration charset:1210// ^[a-z][a-z0-9]*([_-][a-z0-9]+)*$ length ≤ 64.1211//1212// The native MatchCharsetN enforces the leading-letter + tail-charset1213// shape and length bound; this helper also performs the bidi-strip1214// pre-pass. The "no consecutive [_-]" rule from r/sys/users is NOT1215// enforced here (it's a registration-policy rule, not a sanitization1216// concern — registrations go through r/sys/users itself).1217//1218// Returns the (bidi-stripped) input if valid, "" otherwise. On a ""1219// return, do not emit the user-mention markup at all (e.g. skip the1220// `[@user](/u/user)` link); falling back to the raw user-supplied1221// string would defeat the validation.1222func UserName(s string) string {1223 s = markdown.StripBidiAndZeroWidth(s)1224 if markdown.MatchCharsetN(s, userNameFirstLo, userNameFirstHi, userNameRestLo, userNameRestHi, 1, 64) {1225 return s1226 }1227 return ""1228}12291230// BechString validates a bech32-style address-like string.1231//1232// A bech32 string has the shape `<hrp>1<data>`: a human-readable1233// prefix (HRP) that names the family (e.g. `g` for gno addresses,1234// `gpub` for gno pubkeys, `cosmos` for cosmos addresses), the1235// separator character `1`, then a data part carrying the encoded1236// payload as lowercase alphanumerics.1237//1238// If prefix != "", requires s to start with prefix+"1" exactly, and the1239// data part to match ^[a-z0-9]{6,90}$. Use this when you know the1240// expected family:1241//1242// sanitize.BechString(addr, "g") // only g1... (addresses)1243// sanitize.BechString(pk, "gpub") // only gpub1... (pubkeys)1244//1245// If prefix == "", accepts any reasonable bech32 shape:1246// ^[a-z]{1,16}1[a-z0-9]{6,90}$.1247//1248// Syntactic only — does NOT verify the bech32 checksum. Use a true1249// bech32 decoder if you need that. Returns the cleaned input on1250// accept, "" on reject; on "" return, do not emit the address-link1251// markup (the user-supplied bytes have failed shape validation and1252// should not appear unmodified in output).1253func BechString(s, prefix string) string {1254 s = markdown.StripBidiAndZeroWidth(s)1255 if s == "" {1256 return ""1257 }1258 if prefix != "" {1259 // HRP must be lowercase ASCII letters.1260 for i := 0; i < len(prefix); i++ {1261 c := prefix[i]1262 if c < 'a' || c > 'z' {1263 return ""1264 }1265 }1266 need := prefix + "1"1267 if !strings.HasPrefix(s, need) {1268 return ""1269 }1270 data := s[len(need):]1271 if markdown.MatchCharsetN(data, bechDataFirstLo, bechDataFirstHi, bechDataRestLo, bechDataRestHi, 6, 90) {1272 return s1273 }1274 return ""1275 }1276 // prefix == "" — accept any 1-16 char lowercase HRP, then '1', then data.1277 sep := strings.IndexByte(s, '1')1278 if sep < 1 || sep > 16 {1279 return ""1280 }1281 hrp := s[:sep]1282 if !markdown.MatchCharsetN(hrp, bechHrpFirstLo, bechHrpFirstHi, bechHrpRestLo, bechHrpRestHi, 1, 16) {1283 return ""1284 }1285 data := s[sep+1:]1286 if markdown.MatchCharsetN(data, bechDataFirstLo, bechDataFirstHi, bechDataRestLo, bechDataRestHi, 6, 90) {1287 return s1288 }1289 return ""1290}12911292// FootnoteLabel validates an identifier used as a footnote name, link-1293// reference-definition label, or {#id} anchor: ^[A-Za-z0-9_-]{1,64}$.1294// Strips bidi/zero-width first. Returns s if valid, "" otherwise.1295//1296// Use for every shape where a markdown identifier is treated as an1297// opaque key by the parser:1298//1299// - footnote-definition labels: [^FootnoteLabel(name)]: body1300// - footnote-reference labels: see [^FootnoteLabel(name)]1301// - link-reference-definition labels: [FootnoteLabel(label)]: url1302// - reference-link USE labels: [text][FootnoteLabel(label)]1303// - goldmark auto-anchor {#id}: # Heading {#FootnoteLabel(id)}1304//1305// The shared validator name reflects the shared charset and shared1306// security goal — keep untrusted bytes out of any parser-managed1307// identifier slot.1308//1309// On "" return, omit the footnote / LRD / anchor entirely rather than1310// emitting it with raw user bytes.1311func FootnoteLabel(s string) string {1312 s = markdown.StripBidiAndZeroWidth(s)1313 if markdown.MatchCharsetN(s, footnoteLabelFirstLo, footnoteLabelFirstHi, footnoteLabelRestLo, footnoteLabelRestHi, 1, 64) {1314 return s1315 }1316 return ""1317}13181319// LanguageName validates the language tag (a.k.a. "info string") for1320// a fenced code block — the `go` in:1321//1322// ```go1323// fmt.Println("hi")1324// ```1325//1326// Charset: ^[a-zA-Z0-9_+-]{1,32}$ — letters, digits, `_`, `+`, `-`,1327// up to 32 bytes. Strips bidi/zero-width first.1328//1329// Returns the cleaned input if valid, "" otherwise. A "" return means1330// the caller should emit a language-less fence (``` without a tag)1331// rather than letting the user pick the syntax highlighter — which1332// could otherwise be used to inject newlines or block markers into1333// what becomes the opening fence line.1334func LanguageName(s string) string {1335 s = markdown.StripBidiAndZeroWidth(s)1336 if markdown.MatchCharsetN(s, langFirstLo, langFirstHi, langRestLo, langRestHi, 1, 32) {1337 return s1338 }1339 return ""1340}13411342// NestedPrefix validates a prefix string for line-prefixing builders1343// like md.Nested, which prepends `prefix` to every line of content1344// to render the content as a nested/indented sub-block.1345//1346// Allowed: any string matching `^[ \t>]*$` — spaces, tabs, blockquote1347// `>` chars only. Anything else (a `#`, a `-`, a letter) would let a1348// caller turn benign sub-content into a heading, list, or paragraph1349// at the wrong nesting level.1350//1351// Returns s if valid, "" otherwise. Strips bidi/zero-width first —1352// otherwise an invisible character hidden inside a `>` prefix would1353// be replicated on every nested content line, producing per-line1354// display-vs-storage divergence.1355//1356// On "" return, fall back to a known-safe prefix literal (e.g.1357// `"> "`) or skip the nesting entirely. Do not emit the raw1358// user-supplied prefix.1359func NestedPrefix(s string) string {1360 s = markdown.StripBidiAndZeroWidth(s)1361 for i := 0; i < len(s); i++ {1362 c := s[i]1363 if c != ' ' && c != '\t' && c != '>' {1364 return ""1365 }1366 }1367 return s1368}13691370// ----- Primitive -----13711372// CodeFence returns a string of backticks long enough to wrap content1373// as a CommonMark fenced code block without the content's own backticks1374// closing the fence prematurely.1375//1376// Returned length N = max(minCount, longestBacktickRunInContent + 1).1377// Use N backticks both before and after the content:1378//1379// fence := sanitize.CodeFence(userCode, 3)1380// out += fence + "\n" + userCode + "\n" + fence + "\n"1381//1382// Typical minCount values:1383// - 1 for inline code spans (`x`)1384// - 3 for block fenced code (CommonMark §4.5 requires ≥3)1385//1386// `minCount < 1` is clamped to 1. Empty content returns1387// strings.Repeat("`", max(minCount, 1)). Never panics.1388//1389// Most realms should reach for InlineCode / CodeBlock /1390// LanguageCodeBlock below, which call CodeFence internally and emit1391// the full code block for you. Call CodeFence directly only when1392// you're rolling a custom fence emitter (e.g. a renderer that needs1393// the fence length but emits the body differently).1394func CodeFence(content string, minCount int) string {1395 return markdown.CodeFence(content, minCount)1396}13971398// InlineCode wraps user content as a CommonMark inline code span — the1399// `code` in “ `code` “. Use for any user-derived token, identifier,1400// or short literal that should render in monospace inside running1401// prose: variable names, hashes, hex addresses, token symbols, error1402// codes, package paths, transaction IDs.1403//1404// Inline code spans cannot span lines (a `\n` inside the content would1405// end the span and leave the surrounding backticks as literal text),1406// so all line breaks — CR / CRLF / LF, NEL (U+0085), U+2028, U+2029 —1407// are folded to a single space. If you want each line of user content1408// on its own row, use CodeBlock instead.1409//1410// Behavior:1411// - Bidi/zero-width controls are stripped (browsers honor bidi marks1412// inside `<code>`, so leaving them would let stored bytes display1413// as something different).1414// - NUL is replaced with U+FFFD.1415// - The wrapping fence is one backtick longer than the longest1416// backtick run in the content, so internal backticks can never1417// close the span prematurely.1418// - A single space pad is added on each side when content starts or1419// ends with “ ` “ or space, so leading/trailing backticks render1420// literally rather than fusing with the fence (the renderer1421// strips one space from each side per CommonMark spec).1422//1423// Empty input returns "" rather than a literal two-backtick string1424// (which CommonMark parses as text, not as an empty code span). If1425// you use InlineCode as link text and it returns "", omit the link1426// entirely.1427//1428// Not idempotent (see package doc): wraps with a fence, so calling1429// twice double-wraps.1430func InlineCode(content string) string {1431 content = markdown.StripBidiAndZeroWidth(content)1432 content = markdown.NormalizeBreaks(content)1433 content = foldNewlinesAndSeparators(content, ' ')1434 content = replaceNULWithFFFD(content)1435 if content == "" {1436 return ""1437 }1438 fence := markdown.CodeFence(content, 1)1439 pad := ""1440 if content[0] == '`' || content[0] == ' ' ||1441 content[len(content)-1] == '`' || content[len(content)-1] == ' ' {1442 pad = " "1443 }1444 return fence + pad + content + pad + fence1445}14461447// CodeBlock wraps user content as a CommonMark fenced code block.1448// Use for any user-derived multi-line snippet that should render as a1449// code block: log excerpts, JSON dumps, error backtraces, config1450// snippets, posted code samples.1451//1452// Behavior:1453// - Bidi/zero-width controls are stripped.1454// - CR/CRLF line endings are normalized to LF; Unicode separators1455// (NEL U+0085, U+2028, U+2029) are folded to LF for line-count1456// consistency.1457// - NUL is replaced with U+FFFD per CM §2.3.1458// - The wrapping fence is at least 3 backticks (CM §4.5 minimum) and1459// sized to outscan internal backticks — an attacker cannot embed1460// a closing fence in the content.1461//1462// Empty content emits an empty fenced block ("```\n\n```\n"), which is1463// valid CommonMark and renders as an empty `<pre><code></code></pre>`.1464//1465// Not idempotent (see package doc).1466func CodeBlock(content string) string {1467 content = markdown.StripBidiAndZeroWidth(content)1468 content = markdown.NormalizeBreaks(content)1469 content = foldNewlinesAndSeparators(content, '\n')1470 content = replaceNULWithFFFD(content)1471 fence := markdown.CodeFence(content, 3)1472 return fence + "\n" + content + "\n" + fence + "\n"1473}14741475// LanguageCodeBlock wraps user content as a fenced code block tagged1476// with a programming-language hint (the "info string" after the1477// opening fence, e.g. `go` in ```` ```go ````) so the renderer can1478// apply syntax highlighting.1479//1480// An invalid `language` tag silently falls back to a tagless fence —1481// the helper never returns an error or panics. If a realm author is1482// debugging "why is my Go highlighting gone?", the input failed the1483// language validator (charset ^[a-zA-Z0-9_+-]{1,32}$ after bidi-strip).1484// This fallback exists because an unvalidated tag could contain a1485// newline that injects content (e.g. a heading) onto what becomes the1486// opening fence line.1487//1488// Content is cleaned exactly as in CodeBlock (bidi-strip, CR/CRLF1489// normalize to LF, NEL/U+2028/U+2029 fold to LF, NUL→U+FFFD, fence1490// sized to outscan internal backticks).1491//1492// Not idempotent (see package doc).1493func LanguageCodeBlock(language, content string) string {1494 content = markdown.StripBidiAndZeroWidth(content)1495 content = markdown.NormalizeBreaks(content)1496 content = foldNewlinesAndSeparators(content, '\n')1497 content = replaceNULWithFFFD(content)1498 fence := markdown.CodeFence(content, 3)1499 lang := LanguageName(language) // "" on reject1500 return fence + lang + "\n" + content + "\n" + fence + "\n"1501}15021503// ----- Reference-style definitions -----15041505// FootnoteDefinition emits a GFM footnote definition — the1506// `[^name]: body` form that introduces a footnote whose body is rendered1507// in the page footer (or wherever the renderer chooses to place it).1508// Other parts of the markdown reference the footnote by writing1509// `[^name]` inline.1510//1511// Use for any realm-rendered footnote where the body text comes from1512// user input. The realm picks the footnote name (passed as `name`,1513// validated by FootnoteLabel — failure here returns ""); the user's1514// content goes in `text`, which is sanitized via Block.1515//1516// Contract:1517// - `name`: passed raw, validated as a FootnoteLabel1518// (^[A-Za-z0-9_-]{1,64}$). Reject → return "".1519// - `text`: passed raw multi-paragraph user prose, cleaned via Block1520// (bidi-strip, line-ending normalize, LRD strip, block-marker1521// escape, ref-link USE escape, fence auto-close).1522//1523// Empty body → returns "" (a label without body is not a valid1524// footnote definition; the markdown would parse as a paragraph1525// containing the label).1526//1527// Output shape:1528//1529// [^name]:1530// line 1 of body1531// line 2 of body1532// ...1533//1534// The label sits on its own line and each body line gets a 4-space1535// indent — the GFM continuation rule that keeps multi-paragraph body1536// text bound to the footnote rather than detaching as a new paragraph.1537//1538// Not idempotent (see package doc): composes Block internally; passing1539// already-sanitized body text double-escapes.1540func FootnoteDefinition(name, text string) string {1541 label := FootnoteLabel(name)1542 if label == "" {1543 return ""1544 }1545 // Block now wraps with "\n\n" on both sides for cross-paragraph1546 // isolation; inside a footnote-definition's 4-space-indented body1547 // the wrap would line-prefix to blank padding lines, so strip ALL1548 // leading and trailing "\n"s before continuation-indenting.1549 body := strings.Trim(Block(text), "\n")1550 if body == "" {1551 return ""1552 }1553 var b strings.Builder1554 b.WriteString("[^")1555 b.WriteString(label)1556 b.WriteString("]:\n")1557 for _, line := range strings.Split(body, "\n") {1558 if line == "" {1559 b.WriteByte('\n')1560 } else {1561 b.WriteString(" ")1562 b.WriteString(line)1563 b.WriteByte('\n')1564 }1565 }1566 return b.String()1567}15681569// LinkReferenceDefinition emits a CommonMark link reference definition1570// (CM §4.7) — the `[label]: url "title"` form that other parts of the1571// markdown reference by writing `[text][label]` or `[label]` (shortcut).1572//1573// Use for any realm-rendered LRD where the realm owns the label but1574// any of the URL or title come from user input. The user content for1575// the URL goes through URL (allowlist-based — reject → ""); the title1576// goes through LinkTitle (escape).1577//1578// Contract:1579// - `label`: passed raw, validated as a FootnoteLabel1580// (^[A-Za-z0-9_-]{1,64}$). Realms should choose a namespaced label1581// using dashes (e.g. `r-myrealm-help`) so shortcut-reference1582// invocations from user content can't collide with bare prose1583// (`[help]`, `[click here]`). `/` is not in the FootnoteLabel1584// charset; reject → return "".1585// - `url`: passed raw, sanitized via URL. If URL rejects, the LRD is1586// skipped (return "").1587// - `title`: passed raw, sanitized via LinkTitle. Empty title → no1588// title clause emitted.1589//1590// The output is framed with leading and trailing blank lines so that1591// the definition cannot accidentally fuse with adjacent paragraph1592// content into a setext underline or a continuation line.1593//1594// Not idempotent (see package doc).1595func LinkReferenceDefinition(label, url, title string) string {1596 lbl := FootnoteLabel(label)1597 if lbl == "" {1598 return ""1599 }1600 safeURL := URL(url)1601 if safeURL == "" {1602 return ""1603 }1604 var b strings.Builder1605 b.WriteString("\n\n[")1606 b.WriteString(lbl)1607 b.WriteString("]: ")1608 b.WriteString(safeURL)1609 if title != "" {1610 b.WriteString(" \"")1611 b.WriteString(LinkTitle(title))1612 b.WriteString("\"")1613 }1614 b.WriteString("\n\n")1615 return b.String()1616}16171618// ----- internal helpers -----16191620// linkSchemeAllowed returns true if s passes the URL helper's scheme1621// allowlist. See URL's doc for the policy.1622func linkSchemeAllowed(s string) bool {1623 if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {1624 return true1625 }1626 if strings.HasPrefix(s, "mailto:") {1627 // Reject any query: RFC 6068 headers (body, subject, cc, bcc, ...)1628 // prefill the composed message and are a phishing vector. '?' opens1629 // the header section (covering percent-encoded names like ?%62ody=).1630 // '&' is rejected because gnoweb's renderer decodes HTML character1631 // references ('?', '?', '?') back into '?' after this1632 // check, reconstituting a query. Percent-encoded '%3f' stays encoded1633 // and reads as a literal '?' in the address, so it's allowed.1634 if strings.ContainsAny(s, "?&") {1635 return false1636 }1637 return true1638 }1639 if strings.HasPrefix(s, "//") {1640 // Protocol-relative — reject (tracking-pixel vector).1641 return false1642 }1643 // Any URL with an unknown scheme (RFC 3986: `^[a-zA-Z][a-zA-Z0-9+.-]*:`)1644 // is rejected — this blocks `javascript:`, `data:`, `vbscript:`, `blob:`,1645 // and anything else not handled above. URLs without a scheme are1646 // treated as relative and accepted (bare path, query-only, fragment).1647 if hasURLScheme(s) {1648 return false1649 }1650 return true1651}16521653// hasURLScheme reports whether s begins with a scheme followed by ':'1654// per RFC 3986 (^[a-zA-Z][a-zA-Z0-9+.-]*:). A `:` appearing later in1655// the URL (e.g. `/path:foo` or `?q=a:b`) does not count.1656func hasURLScheme(s string) bool {1657 if len(s) == 0 {1658 return false1659 }1660 c := s[0]1661 if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {1662 return false1663 }1664 for i := 1; i < len(s); i++ {1665 c := s[i]1666 if c == ':' {1667 return true1668 }1669 if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||1670 (c >= '0' && c <= '9') || c == '+' || c == '.' || c == '-') {1671 return false1672 }1673 }1674 return false1675}16761677// imageSchemeAllowed returns true if s passes the ImageURL helper's1678// scheme allowlist. Tighter than linkSchemeAllowed: no mailto/tel,1679// only data:image/<subset>.1680func imageSchemeAllowed(s string) bool {1681 if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {1682 return true1683 }1684 if strings.HasPrefix(s, "//") {1685 return false1686 }1687 if strings.HasPrefix(s, "/") || strings.HasPrefix(s, "./") || strings.HasPrefix(s, "../") {1688 return true1689 }1690 if strings.HasPrefix(s, "data:") {1691 // Only the curated image/* subset. CSS must enforce sizing.1692 for _, p := range []string{1693 "data:image/svg+xml",1694 "data:image/png",1695 "data:image/jpeg",1696 "data:image/gif",1697 "data:image/webp",1698 } {1699 if strings.HasPrefix(s, p) {1700 return true1701 }1702 }1703 return false1704 }1705 return false1706}17071708// foldNewlinesAndSeparators replaces \n, U+0085 NEL, U+2028 LINE SEPARATOR,1709// U+2029 PARAGRAPH SEPARATOR with the given replacement byte (typically1710// space for inline-context helpers).1711//1712// NormalizeBreaks has already folded \r\n and \r to \n before this runs,1713// so \n is the canonical break byte to substitute.1714func foldNewlinesAndSeparators(s string, replacement byte) string {1715 if !needsSeparatorFold(s) {1716 return s1717 }1718 out := make([]byte, 0, len(s))1719 for i := 0; i < len(s); {1720 c := s[i]1721 if c == '\n' {1722 out = append(out, replacement)1723 i++1724 continue1725 }1726 // U+0085 NEL: 0xC2 0x851727 if c == 0xC2 && i+1 < len(s) && s[i+1] == 0x85 {1728 out = append(out, replacement)1729 i += 21730 continue1731 }1732 // U+2028 (0xE2 0x80 0xA8) or U+2029 (0xE2 0x80 0xA9)1733 if c == 0xE2 && i+2 < len(s) && s[i+1] == 0x80 && (s[i+2] == 0xA8 || s[i+2] == 0xA9) {1734 out = append(out, replacement)1735 i += 31736 continue1737 }1738 out = append(out, c)1739 i++1740 }1741 return string(out)1742}17431744func needsSeparatorFold(s string) bool {1745 for i := 0; i < len(s); i++ {1746 c := s[i]1747 if c == '\n' || c == 0xC2 || c == 0xE2 {1748 return true1749 }1750 }1751 return false1752}17531754// replaceNULWithFFFD substitutes any NUL byte with the UTF-8 encoding1755// of U+FFFD REPLACEMENT CHARACTER per CM §2.3.1756func replaceNULWithFFFD(s string) string {1757 if !strings.ContainsRune(s, 0) {1758 return s1759 }1760 return strings.ReplaceAll(s, "\x00", "\ufffd")1761}1762Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.