sanitize_test.gno
18.67 Kb · 493 lines
1package sanitize
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestStripBidiAndZeroWidthRe(t *testing.T) {
9 // Re-export sanity check.
10 if got := StripBidiAndZeroWidth("a\u200Bb"); got != "ab" {
11 t.Errorf("re-export StripBidiAndZeroWidth failed: got %q", got)
12 }
13}
14
15func TestNormalizeBreaksRe(t *testing.T) {
16 if got := NormalizeBreaks("a\r\nb"); got != "a\nb" {
17 t.Errorf("re-export NormalizeBreaks failed: got %q", got)
18 }
19}
20
21func TestInlineText(t *testing.T) {
22 cases := []struct{ in, want string }{
23 {"plain", "plain"},
24 {"a*b", `a\*b`},
25 {"a|b", "a|b"}, // | NOT escaped
26 {"a=b", "a=b"}, // = NOT escaped
27 {"line1\nline2", "line1 line2"}, // newline folded
28 {"line1\r\nline2", "line1 line2"}, // CRLF normalized then folded
29 {"a\u2028b", "a b"}, // U+2028 folded
30 {"a\u0085b", "a b"}, // NEL folded
31 {"a\u200Bb", "ab"}, // ZWSP stripped
32 {"hello *world*", `hello \*world\*`},
33 }
34 for _, c := range cases {
35 if got := InlineText(c.in); got != c.want {
36 t.Errorf("InlineText(%q) = %q, want %q", c.in, got, c.want)
37 }
38 }
39}
40
41func TestBlock(t *testing.T) {
42 // Block wraps every non-empty output with "\n\n" — CM §4.8 blank
43 // lines — on BOTH sides, so user content is paragraph-isolated
44 // from any realm chrome that precedes OR follows it. Bounds CM
45 // §4.6 HTML block types 6/7 (`<div>`, `<table>`, …) which are not
46 // escaped in any mode and would otherwise consume appended chrome.
47 cases := []struct{ in, want string }{
48 {"hello world\n", "\n\nhello world\n\n"},
49 {"# heading\n", "\n\n\\# heading\n\n"},
50 {"> quote\n", "\n\n\\> quote\n\n"},
51 {"| a | b |\n", "\n\n\\| a | b |\n\n"}, // GFM table-row escaped in strict mode
52 // CM §4.6 HTML block types 1-5 — escaped (blank-line-NON-terminating).
53 {"<script>x</script>\n", "\n\n\\<script>x</script>\n\n"},
54 {"<!-- comment -->\n", "\n\n\\<!-- comment -->\n\n"},
55 {"<?php x ?>\n", "\n\n\\<?php x ?>\n\n"},
56 {"<!DOCTYPE html>\n", "\n\n\\<!DOCTYPE html>\n\n"},
57 {"```\ncode\n", "\n\n```\ncode\n```\n\n"}, // fence auto-close at EOF
58 {"[x](javascript:alert)\n", "\n\n[x](%26#x6a;avascript:alert)\n\n"},
59 {"[x](˜avascript:alert)\n", "\n\n[x](%26#0000152;avascript:alert)\n\n"}, // octal reference
60 // A renderer unescapes `\#` before resolving references, so this
61 // reaches it as `j` unless the `&` is encoded here.
62 {"[x](&\\#x6a;avascript:alert)\n", "\n\n[x](%26\\#x6a;avascript:alert)\n\n"},
63 // A renderer resolves numeric references before named ones, over
64 // the same buffer, so `&colon;` reaches the named pass as
65 // `:` and resolves to `:`.
66 {"[x](javascript&colon;alert)\n", "\n\n[x](javascript%26#38;colon;alert)\n\n"},
67 {"[x](?x=1&y=2)\n", "\n\n[x](?x=1&y=2)\n\n"},
68 // Empty / strip-to-empty inputs short-circuit (no stray blank line).
69 {"[x]: y\n", ""},
70 {"", ""},
71 }
72 for _, c := range cases {
73 got := Block(c.in)
74 if got != c.want {
75 t.Errorf("Block(%q) = %q, want %q", c.in, got, c.want)
76 }
77 // Idempotency: Block(Block(in)) must be byte-identical to
78 // Block(in) for every input in the table.
79 twice := Block(got)
80 if twice != got {
81 t.Errorf("Block not idempotent for %q: Block(once)=%q, Block(twice)=%q", c.in, got, twice)
82 }
83 }
84}
85
86func TestBlockRich(t *testing.T) {
87 // BlockRich wraps every non-empty output with "\n\n" — CM §4.8
88 // blank lines — on BOTH sides, so user content is paragraph-
89 // isolated from any realm chrome that precedes OR follows it.
90 cases := []struct{ in, want string }{
91 // Block-level markdown that Block escapes — preserved by BlockRich.
92 {"hello world\n", "\n\nhello world\n\n"},
93 {"# heading\n", "\n\n# heading\n\n"},
94 {"> quote\n", "\n\n> quote\n\n"},
95 {"- item\n", "\n\n- item\n\n"},
96 {"1. item\n", "\n\n1. item\n\n"},
97 {"---\n", "\n\n\\---\n\n"}, // first-line setext-h2 → escaped by qualifying-setext pre-pass
98 {"***\n", "\n\n***\n\n"}, // thematic break NOT setext-h2 — preserved
99 {"text\n===\n", "\n\ntext\n===\n\n"}, // deeper setext preserved (user-authored above)
100 {"body\n===\nmore\n", "\n\nbody\n===\nmore\n\n"}, // cross-paragraph backward attack: realm `chrome\n\nbody\n===\nmore` keeps realm in its own paragraph
101 // GFM tables PRESERVED in Rich mode.
102 {"| a | b |\n", "\n\n| a | b |\n\n"},
103 {"| H |\n|---|\n| a |\n", "\n\n| H |\n|---|\n| a |\n\n"}, // full table renders as <table>
104 // Realm-binding defenses STILL ON.
105 {"<gno-card>\n", "\n\n\\<gno-card>\n\n"},
106 // CM §4.6 HTML block types 1-5 — escaped in Rich mode too
107 // (defense is mode-independent).
108 {"<script>x</script>\n", "\n\n\\<script>x</script>\n\n"},
109 {"<!-- comment -->\n", "\n\n\\<!-- comment -->\n\n"},
110 {"<?php x ?>\n", "\n\n\\<?php x ?>\n\n"},
111 {"<!DOCTYPE html>\n", "\n\n\\<!DOCTYPE html>\n\n"},
112 {"[t][l]\n", "\n\n\\[t\\]\\[l\\]\n\n"},
113 {"[^name]\n", "\n\n\\[^name\\]\n\n"},
114 // LRD-only input strips to nothing; empty-after-escape short-
115 // circuits to "" so realm concatenation doesn't leak a stray
116 // blank line.
117 {"[x]: y\n", ""},
118 {"", ""}, // empty input → empty output, no stray blank line
119 {"```\ncode\n", "\n\n```\ncode\n```\n\n"},
120 {"[x](javascript:alert)\n", "\n\n[x](javascript%26colon;alert)\n\n"},
121 // `\;` on a named reference — unescaped to `:` by a renderer.
122 {"[x](javascript&colon\\;alert)\n", "\n\n[x](javascript%26colon\\;alert)\n\n"},
123 // Numeric reference feeding the named pass — see TestBlock.
124 {"[x](javascript&colon;alert)\n", "\n\n[x](javascript%26#x26;colon;alert)\n\n"},
125 {"[x](/r/boards$help&func=DeleteThread&boardID=2)\n", "\n\n[x](/r/boards$help&func=DeleteThread&boardID=2)\n\n"},
126 }
127 for _, c := range cases {
128 got := BlockRich(c.in)
129 if got != c.want {
130 t.Errorf("BlockRich(%q) = %q, want %q", c.in, got, c.want)
131 }
132 // Idempotency: BlockRich(BlockRich(in)) must be byte-identical
133 // to BlockRich(in) for every input in the table.
134 twice := BlockRich(got)
135 if twice != got {
136 t.Errorf("BlockRich not idempotent for %q: BlockRich(once)=%q, BlockRich(twice)=%q", c.in, got, twice)
137 }
138 }
139}
140
141func TestBlockRich_LeadingBlankLineGuaranteed(t *testing.T) {
142 // Every non-empty result begins with "\n\n" so `chrome +
143 // BlockRich(user)` cannot place the user's first line in the same
144 // paragraph as the realm's last line (which would let a deeper
145 // `===` or `|---|` in user content retroactively promote realm
146 // chrome).
147 for _, in := range []string{"x", "x\n", "# h", "- i\n- j", "| a |\n|---|\n| 1 |"} {
148 got := BlockRich(in)
149 if got == "" {
150 continue
151 }
152 if !strings.HasPrefix(got, "\n\n") {
153 t.Errorf("BlockRich(%q) = %q; expected leading '\\n\\n'", in, got)
154 }
155 }
156}
157
158func TestBlockRich_TrailingBlankLineGuaranteed(t *testing.T) {
159 // Every non-empty result ends with "\n\n" so `BlockRich(user) +
160 // chrome` cannot extend user's last paragraph into the realm's
161 // next line (CM §5.2 lazy continuation, or a realm-supplied
162 // `|---|` row retroactively promoting user's last line into a
163 // `<thead>` header).
164 for _, in := range []string{"x", "x\n", "# h", "- i\n- j", "| H |\n|---|\n| a |"} {
165 got := BlockRich(in)
166 if got == "" {
167 continue
168 }
169 if !strings.HasSuffix(got, "\n\n") {
170 t.Errorf("BlockRich(%q) = %q; expected trailing '\\n\\n'", in, got)
171 }
172 }
173}
174
175func TestBlockquoteRich(t *testing.T) {
176 // BlockquoteRich strips BlockRich's leading "\n", line-prefixes
177 // each remaining line with "> ", and emits "\n" on both ends:
178 // leading "\n" prevents `chrome + BlockquoteRich(user)` from
179 // landing the first `>` mid-line; trailing "\n\n" (blank line)
180 // prevents `BlockquoteRich(user) + chrome` from pulling chrome
181 // into the quote via CM §5.2 lazy continuation.
182 cases := []struct{ name, in, want string }{
183 {"plain text", "hello world\n", "\n> hello world\n\n"},
184 {"atx heading preserved", "# heading\n", "\n> # heading\n\n"},
185 {"nested blockquote", "> nested\n", "\n> > nested\n\n"},
186 {"list item preserved", "- item\n", "\n> - item\n\n"},
187 {"ordered list preserved", "1. item\n", "\n> 1. item\n\n"},
188 {"first-line setext escaped", "---\n", "\n> \\---\n\n"},
189 {"deeper setext preserved", "text\n===\n", "\n> text\n> ===\n\n"},
190 {"thematic break asterisks preserved", "***\n", "\n> ***\n\n"},
191 // Realm-binding defenses still on.
192 {"gno extension escaped", "<gno-card>\n", "\n> \\<gno-card>\n\n"},
193 {"ref-link use escaped", "[t][l]\n", "\n> \\[t\\]\\[l\\]\n\n"},
194 {"footnote escaped", "[^name]\n", "\n> \\[^name\\]\n\n"},
195 // LRDs are stripped by BlockRich entirely; trimming the
196 // resulting bare "\n" leaves empty input and the helper
197 // returns "" without emitting a blockquote.
198 {"lrd alone strips to empty", "[x]: y\n", ""},
199 // Code fence autoclose at EOF lands inside the quote.
200 {"unclosed fence autoclosed", "```\ncode\n", "\n> ```\n> code\n> ```\n\n"},
201 // Multi-paragraph preserves the inner blank line (rendered
202 // as a quoted blank line `> \n`).
203 {"multi-paragraph preserves blank", "a\n\nb\n", "\n> a\n> \n> b\n\n"},
204 // Empty / blank-only input collapses cleanly to "".
205 {"empty input", "", ""},
206 // CRLF normalized through BlockRich.
207 {"crlf normalized", "a\r\nb\n", "\n> a\n> b\n\n"},
208 // NUL replaced with U+FFFD by BlockRich.
209 {"nul replaced", "x\x00y\n", "\n> x\uFFFDy\n\n"},
210 // Multiple trailing newlines collapse to the single trailing
211 // blank line shape — output never ends with more than `\n\n`.
212 {"multiple trailing newlines collapse", "foo\n\n\n", "\n> foo\n\n"},
213 // Internal tabs are preserved (BlockRich does not touch them).
214 {"internal tab preserved", "a\tb\n", "\n> a\tb\n\n"},
215 // Whitespace-only input still yields a blockquote (the user
216 // wrote literal spaces). The line-prefix loop emits `> ` plus
217 // the original two spaces.
218 {"whitespace-only input", " ", "\n> \n\n"},
219 }
220 for _, c := range cases {
221 got := BlockquoteRich(c.in)
222 if got != c.want {
223 t.Errorf("%s: BlockquoteRich(%q) = %q, want %q", c.name, c.in, got, c.want)
224 }
225 }
226}
227
228func TestBlockquoteRich_DoubleWrapNestsQuote(t *testing.T) {
229 // Not idempotent: calling twice nests the quote one level
230 // deeper. The aggressive TrimRight in BlockquoteRich also
231 // collapses the inner trailing blank line, so the second pass
232 // produces a clean `> > foo` nesting (no `> ` quoted blank in
233 // the middle) plus the outer trailing blank line.
234 once := BlockquoteRich("foo\n")
235 twice := BlockquoteRich(once)
236 if want := "\n> > foo\n\n"; twice != want {
237 t.Errorf("BlockquoteRich(BlockquoteRich(%q)) = %q, want %q", "foo\n", twice, want)
238 }
239}
240
241func TestBlockquoteRich_LeadingNewlineGuaranteed(t *testing.T) {
242 // Every non-empty result starts with "\n" so realm concatenation
243 // like `chrome + BlockquoteRich(user)` doesn't place `>` mid-line.
244 for _, in := range []string{"x", "x\n", "# h", "- i\n- j"} {
245 got := BlockquoteRich(in)
246 if got == "" {
247 continue
248 }
249 if got[0] != '\n' {
250 t.Errorf("BlockquoteRich(%q) = %q; expected leading '\\n'", in, got)
251 }
252 }
253}
254
255func TestBlockquoteRich_TrailingBlankLineGuaranteed(t *testing.T) {
256 // Every non-empty result ends with "\n\n" so realm concatenation
257 // like `BlockquoteRich(user) + "more text"` doesn't pull "more
258 // text" into the quote via CM §5.2 lazy continuation.
259 for _, in := range []string{"x", "x\n", "# h", "- i\n- j", "para\n\nmore"} {
260 got := BlockquoteRich(in)
261 if got == "" {
262 continue
263 }
264 if !strings.HasSuffix(got, "\n\n") {
265 t.Errorf("BlockquoteRich(%q) = %q; expected trailing '\\n\\n'", in, got)
266 }
267 }
268}
269
270func TestBlockquoteRich_NoStrayEmptyQuotedLine(t *testing.T) {
271 // BlockRich's own leading "\n" must be stripped before
272 // line-prefixing — otherwise the output would start with a
273 // useless `> \n` empty quoted line.
274 got := BlockquoteRich("foo\n")
275 if strings.HasPrefix(got, "\n> \n") {
276 t.Errorf("BlockquoteRich leaked BlockRich's leading '\\n' as `> \\n`: %q", got)
277 }
278}
279
280func TestNeuterLeadingSetextIfQualifying(t *testing.T) {
281 cases := []struct{ name, in, want string }{
282 {"leading-setext-h1", "===\nbody\n", "\\===\nbody\n"},
283 {"leading-setext-h2", "---\nbody\n", "\\---\nbody\n"},
284 {"leading-setext-indented", " ===\nbody\n", " \\===\nbody\n"},
285 {"leading-setext-indented-4", " ===\nbody\n", " ===\nbody\n"}, // indented code
286 {"leading-setext-trailing-ws", "=== \nbody\n", "\\=== \nbody\n"},
287 {"leading-setext-mixed-chars", "=-=-\nbody\n", "=-=-\nbody\n"}, // mixed; not setext
288 {"leading-setext-has-content", "=== text\nbody\n", "=== text\nbody\n"}, // mixed content
289 {"setext-deeper-untouched", "title\n===\nfoo\n", "title\n===\nfoo\n"},
290 {"leading-thematic-asterisk", "***\nbody\n", "***\nbody\n"}, // not setext, untouched
291 {"leading-thematic-underscore", "___\nbody\n", "___\nbody\n"},
292 {"leading-blank-then-setext", "\n===\nbody\n", "\n\\===\nbody\n"},
293 {"leading-blank-ws-then-setext", " \n===\nbody\n", " \n\\===\nbody\n"},
294 {"text-first", "hello\n===\n", "hello\n===\n"}, // text before === — user-authored
295 {"empty", "", ""},
296 {"all-blank", " \n\n", " \n\n"},
297 {"only-equals", "===", "\\==="},
298 }
299 for _, c := range cases {
300 if got := neuterLeadingSetextIfQualifying(c.in); got != c.want {
301 t.Errorf("%s: neuterLeadingSetextIfQualifying(%q) = %q, want %q", c.name, c.in, got, c.want)
302 }
303 }
304}
305
306func TestLinkTitle(t *testing.T) {
307 cases := []struct{ in, want string }{
308 {`he said "hi"`, `he said \"hi\"`},
309 {`it's nice`, `it\'s nice`},
310 {"line1\nline2", "line1 line2"},
311 }
312 for _, c := range cases {
313 if got := LinkTitle(c.in); got != c.want {
314 t.Errorf("LinkTitle(%q) = %q, want %q", c.in, got, c.want)
315 }
316 }
317}
318
319func TestTableCell(t *testing.T) {
320 cases := []struct{ in, want string }{
321 {"plain cell", "plain cell"},
322 {"a|b", `a\|b`},
323 {"a\tb", "a b"},
324 {"a*b|c", `a\*b\|c`},
325 }
326 for _, c := range cases {
327 if got := TableCell(c.in); got != c.want {
328 t.Errorf("TableCell(%q) = %q, want %q", c.in, got, c.want)
329 }
330 }
331}
332
333func TestHTMLEscape(t *testing.T) {
334 cases := []struct{ in, want string }{
335 {"plain", "plain"},
336 {"<script>", "<script>"},
337 {`a & b`, "a & b"},
338 {`"quoted"`, ""quoted""},
339 }
340 for _, c := range cases {
341 if got := HTMLEscape(c.in); got != c.want {
342 t.Errorf("HTMLEscape(%q) = %q, want %q", c.in, got, c.want)
343 }
344 }
345}
346
347func TestURL(t *testing.T) {
348 cases := []struct{ in, want string }{
349 {"https://example.com/x", "https://example.com/x"},
350 {"http://example.com", "http://example.com"},
351 {"mailto:[email protected]", "mailto:[email protected]"},
352 {"mailto:[email protected]?body=phish", ""}, // any query is a prefill-phishing vector
353 {"mailto:[email protected]?BODY=phish", ""}, // header name case is irrelevant
354 {"mailto:[email protected]?Body=phish", ""}, // mixed case
355 {"mailto:[email protected]?%62ody=phish", ""}, // percent-encoded header (%62 == b)
356 {"mailto:[email protected]?subject=hi&body=phish", ""}, // body as second param
357 {"mailto:[email protected]?subject=hi&BODY=phish", ""}, // mixed case second param
358 {"mailto:[email protected]?subject=hello", ""}, // subject alone still rejected
359 {"mailto:[email protected][email protected]", ""}, // cc rejected
360 {"mailto:[email protected][email protected]", ""}, // bcc rejected
361 {"mailto:[email protected]?", ""}, // bare trailing query marker rejected
362 {"mailto:[email protected]?body=phish", ""}, // hex char-ref ? — renderer decodes to ?body=
363 {"mailto:[email protected]?body=phish", ""}, // decimal char-ref ? — same bypass
364 {"mailto:[email protected]?body=phish", ""}, // named char-ref ? — same bypass
365 {"mailto:[email protected]&body=phish", ""}, // bare & gateway rejected
366 {"//evil.com", ""}, // protocol-relative rejected
367 {"javascript:alert(1)", ""}, // bad scheme
368 {"/r/foo", "/r/foo"}, // relative
369 {"./local", "./local"},
370 {"#section", "#section"}, // fragment-only
371 {"", ""},
372 {" ", ""},
373 {"https://a.com/path with space", "https://a.com/path%20with%20space"},
374 }
375 for _, c := range cases {
376 if got := URL(c.in); got != c.want {
377 t.Errorf("URL(%q) = %q, want %q", c.in, got, c.want)
378 }
379 }
380}
381
382func TestImageURL(t *testing.T) {
383 cases := []struct{ in, want string }{
384 {"https://example.com/img.png", "https://example.com/img.png"},
385 {"mailto:[email protected]", ""}, // mailto rejected for images
386 {"data:image/svg+xml,<svg/>", "data:image/svg+xml,%3Csvg/%3E"},
387 {"data:image/png;base64,XXX", "data:image/png;base64,XXX"},
388 {"data:text/html,<script>", ""}, // bad data subset
389 {"javascript:alert(1)", ""},
390 {"", ""},
391 }
392 for _, c := range cases {
393 if got := ImageURL(c.in); got != c.want {
394 t.Errorf("ImageURL(%q) = %q, want %q", c.in, got, c.want)
395 }
396 }
397}
398
399func TestUserName(t *testing.T) {
400 cases := []struct{ in, want string }{
401 {"alice", "alice"},
402 {"alice123", "alice123"},
403 {"alice_bob-cat", "alice_bob-cat"},
404 {"Alice", ""}, // uppercase first
405 {"1alice", ""}, // digit first
406 {"", ""},
407 {"a\u200Blice", "alice"}, // bidi stripped, then matches
408 }
409 for _, c := range cases {
410 if got := UserName(c.in); got != c.want {
411 t.Errorf("UserName(%q) = %q, want %q", c.in, got, c.want)
412 }
413 }
414}
415
416func TestBechString(t *testing.T) {
417 addrG := "g1abc123def456ghi789jkl012mno345p"
418 cases := []struct {
419 s, prefix string
420 want string
421 }{
422 {addrG, "g", addrG},
423 {addrG, "", addrG}, // any prefix
424 {addrG, "gpub", ""}, // wrong prefix
425 {"gpub1abc123def456ghijklmn", "gpub", "gpub1abc123def456ghijklmn"},
426 {"gpub1abc123def456ghijklmn", "", "gpub1abc123def456ghijklmn"},
427 {"b1xyz789abc123def456", "", "b1xyz789abc123def456"}, // any-prefix mode allows b1...
428 {"g1ABC", "g", ""}, // uppercase rejected
429 {"x", "g", ""},
430 {"", "g", ""},
431 }
432 for _, c := range cases {
433 if got := BechString(c.s, c.prefix); got != c.want {
434 t.Errorf("BechString(%q,%q) = %q, want %q", c.s, c.prefix, got, c.want)
435 }
436 }
437}
438
439func TestFootnoteLabel(t *testing.T) {
440 cases := []struct{ in, want string }{
441 {"note1", "note1"},
442 {"Note_A-1", "Note_A-1"},
443 {"with space", ""},
444 {"", ""},
445 }
446 for _, c := range cases {
447 if got := FootnoteLabel(c.in); got != c.want {
448 t.Errorf("FootnoteLabel(%q) = %q, want %q", c.in, got, c.want)
449 }
450 }
451}
452
453func TestLanguageName(t *testing.T) {
454 cases := []struct{ in, want string }{
455 {"go", "go"},
456 {"c++", "c++"},
457 {"python3", "python3"},
458 {"objective-c", "objective-c"},
459 {"with space", ""},
460 {"", ""},
461 }
462 for _, c := range cases {
463 if got := LanguageName(c.in); got != c.want {
464 t.Errorf("LanguageName(%q) = %q, want %q", c.in, got, c.want)
465 }
466 }
467}
468
469func TestNestedPrefix(t *testing.T) {
470 cases := []struct{ in, want string }{
471 {"", ""},
472 {" ", " "},
473 {"\t", "\t"},
474 {"> ", "> "},
475 {"> > ", "> > "},
476 {"## ", ""}, // markdown-active prefix rejected
477 {"- ", ""},
478 }
479 for _, c := range cases {
480 if got := NestedPrefix(c.in); got != c.want {
481 t.Errorf("NestedPrefix(%q) = %q, want %q", c.in, got, c.want)
482 }
483 }
484}
485
486func TestCodeFence(t *testing.T) {
487 if got := CodeFence("```", 3); got != "````" {
488 t.Errorf("CodeFence: got %q, want %q", got, "````")
489 }
490 if got := CodeFence("", 3); got != "```" {
491 t.Errorf("CodeFence empty: got %q, want %q", got, "```")
492 }
493}