// SPDX-License-Identifier: AGPL-3.0-or-later package markdown import ( "context" "strings" "testing" ) // TestRender_HostileInputs is the XSS-vector cheatsheet. Every // fixture is a markdown body that *attempts* to inject executable // JS through a different vector. The pass condition: the rendered // HTML contains no `alert(1)`, ``, ``, // Inline event handlers. ``, ``, `x`, ``, // Style with expressions. ``, `
x
`, // javascript: links. `[click](javascript:alert(1))`, `x`, `x`, `[click](JAVASCRIPT:alert(1))`, // data: URIs (we disallow even data:image). ``, `[x](data:text/html,)`, // vbscript:. `x`, // SVG-embedded scripts. ``, ``, // iframes. ``, ``, // HTML in markdown link text doesn't escape sanitizer. `[](https://example.com)`, // Mutation XSS via mismatched quotes. `x`, // Encoded payloads. `x`, `x`, // Backticked code-like content shouldn't escape. "``", // Embedded in autolinks. ``, // Object/embed. ``, ``, // Form/button with formaction. `
`, // Meta refresh. ``, // Base href hijack. ``, // MathML / annotation. ``, // CSS expression (legacy IE). `
x
`, // Nested fenced code with a script. "```\n\n```", // Markdown link href with newlines. "[x](\njavascript:alert(1))", // Image with javascript:. `![x](javascript:alert(1))`, // HTML entities in URI. `[x](javascript:alert(1))`, // Hex / decimal entities in href attribute. `x`, // Tab/newline obfuscation. "x", "x", // Polyglot HTML+SVG. ``, // Anchor with target=_blank but no rel (we want rel auto-set). `x`, } for i, src := range vectors { out, _, _, err := Render(context.Background(), []byte(src), Options{}) if err != nil { t.Fatalf("vector %d render error: %v", i, err) } // Lower-case for case-insensitive substring search. We // distinguish "executable surface" from "harmless text". // Plain-text "javascript:" in prose is safe; "javascript:" // inside href/src is an XSS — guard the latter shape only. s := strings.ToLower(string(out)) for _, bad := range []string{ "", `href="javascript:`, `href='javascript:`, `src="javascript:`, `src='javascript:`, `href="vbscript:`, `src="vbscript:`, `href="data:`, `src="data:text`, `src="data:image`, " onerror=", " onload=", " onclick=", " onmouseover=", "`, ``, ``, ``, ``, task-list // checkboxes, language-* class on code blocks, or auto-heading IDs. func TestRender_AllowsSafeHTML(t *testing.T) { t.Parallel() cases := []struct { name string src string mustContain []string }{ { "details + summary", "
clicksecret
", []string{"
", "", "click", "secret"}, }, { "kbd", "press Ctrl+C", []string{"Ctrl", "C"}, }, { "sup/sub", "x2 + yi", []string{"2", "i"}, }, { "task list", "- [x] done\n- [ ] not yet\n", []string{"

shithub

`, []string{`

`, ``}, }, { "GFM table", "| a | b |\n|---|---|\n| 1 | 2 |\n", []string{"", "", ""}, }, { "strikethrough", "~~obsolete~~", []string{"obsolete"}, }, { "autolink", "https://example.com", []string{`href="https://example.com"`}, }, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { t.Parallel() out, _, _, err := Render(context.Background(), []byte(c.src), Options{}) if err != nil { t.Fatalf("render: %v", err) } s := string(out) for _, want := range c.mustContain { if !strings.Contains(s, want) { t.Errorf("expected %q in output, got %q", want, s) } } }) } } // TestRender_MentionResolution checks that @user resolves when the // resolver returns ok and stays plain text otherwise. func TestRender_MentionResolution(t *testing.T) { t.Parallel() resolver := func(_ context.Context, name string) (string, bool) { if name == "alice" { return "/alice", true } return "", false } out, _, mentions, err := Render(context.Background(), []byte("hi @alice and @bob"), Options{ Resolvers: Resolvers{User: resolver}, }) if err != nil { t.Fatalf("render: %v", err) } s := string(out) if !strings.Contains(s, `href="/alice"`) { t.Errorf("expected @alice link, got %q", s) } if strings.Contains(s, `href="/bob"`) { t.Errorf("@bob should not link, got %q", s) } if len(mentions) != 1 || mentions[0].Username != "alice" { t.Errorf("expected 1 mention (alice), got %v", mentions) } } // TestRender_TeamMentionResolution: @org/team renders via the Team // resolver and falls back to plain text when the resolver declines // (e.g. secret team invisible to viewer). S31. func TestRender_TeamMentionResolution(t *testing.T) { t.Parallel() teamResolver := func(_ context.Context, org, team string, _ int64) (string, bool) { if org == "acme" && team == "eng" { return "/acme/teams/eng", true } return "", false } out, _, _, err := Render(context.Background(), []byte("ping @acme/eng and @acme/secret here"), Options{ Resolvers: Resolvers{Team: teamResolver}, }) if err != nil { t.Fatalf("render: %v", err) } s := string(out) if !strings.Contains(s, `href="/acme/teams/eng"`) { t.Errorf("expected @acme/eng link, got %q", s) } if strings.Contains(s, `href="/acme/teams/secret"`) { t.Errorf("@acme/secret should not link, got %q", s) } } // TestRender_IssueRefResolution checks both same-repo and cross-repo // refs, and that an unresolvable ref renders as plain text (no link). func TestRender_IssueRefResolution(t *testing.T) { t.Parallel() resolver := func(_ context.Context, owner, name string, num int64, _ int64) (string, bool) { // Same-repo refs leave owner+name empty. if owner == "" && name == "" && num == 7 { return "/o/r/issues/7", true } if owner == "alice" && name == "proj" && num == 3 { return "/alice/proj/issues/3", true } return "", false } out, refs, _, err := Render(context.Background(), []byte("see #7 and alice/proj#3, but not bob/x#9"), Options{ Resolvers: Resolvers{Issue: resolver}, }) if err != nil { t.Fatalf("render: %v", err) } s := string(out) if !strings.Contains(s, `href="/o/r/issues/7"`) { t.Errorf("expected #7 link, got %q", s) } if !strings.Contains(s, `href="/alice/proj/issues/3"`) { t.Errorf("expected alice/proj#3 link, got %q", s) } if strings.Contains(s, `href="/bob/x/issues/9"`) { t.Errorf("bob/x#9 should not link, got %q", s) } if len(refs) != 2 { t.Errorf("expected 2 refs, got %v", refs) } } // TestRender_RefsInsideCodeAreInert confirms that #N inside inline // code or fenced code stays as text. func TestRender_RefsInsideCodeAreInert(t *testing.T) { t.Parallel() resolver := func(_ context.Context, owner, name string, num int64, _ int64) (string, bool) { return "/should/not/appear", true } src := "Inline `#7` and:\n\n```\nblock #7 here\n```" out, refs, _, err := Render(context.Background(), []byte(src), Options{ Resolvers: Resolvers{Issue: resolver}, }) if err != nil { t.Fatalf("render: %v", err) } if strings.Contains(string(out), "/should/not/appear") { t.Errorf("ref leaked into code block: %q", out) } if len(refs) != 0 { t.Errorf("expected 0 refs inside code, got %v", refs) } } // TestRender_EmojiShortcodes checks the curated set works. func TestRender_EmojiShortcodes(t *testing.T) { t.Parallel() out, _, _, err := Render(context.Background(), []byte("ship it :rocket: :+1: :notrealemoji:"), Options{}) if err != nil { t.Fatalf("render: %v", err) } s := string(out) if !strings.Contains(s, "🚀") { t.Errorf("expected rocket emoji in output, got %q", s) } if !strings.Contains(s, "👍") { t.Errorf("expected +1 emoji in output, got %q", s) } if !strings.Contains(s, ":notrealemoji:") { t.Errorf("unknown shortcode should pass through, got %q", s) } } // TestRender_InputTooLarge enforces the renderer's defensive cap. func TestRender_InputTooLarge(t *testing.T) { t.Parallel() big := make([]byte, MaxRenderInputBytes+1) for i := range big { big[i] = 'x' } if _, _, _, err := Render(context.Background(), big, Options{}); err == nil { t.Errorf("expected ErrInputTooLarge") } } // TestRender_SoftBreakAsBR controls the comment-vs-readme newline // handling. func TestRender_SoftBreakAsBR(t *testing.T) { t.Parallel() src := "line one\nline two\n" br, _, _, _ := Render(context.Background(), []byte(src), Options{SoftBreakAsBR: true}) noBR, _, _, _ := Render(context.Background(), []byte(src), Options{SoftBreakAsBR: false}) if !strings.Contains(string(br), ", got %q", br) } if strings.Contains(string(noBR), ", got %q", noBR) } } // TestRender_BackCompatRenderHTML keeps the old shim working so the // interim S17/S21/S22 callers don't need rewrite during S25. func TestRender_BackCompatRenderHTML(t *testing.T) { t.Parallel() html, err := RenderHTML([]byte("**bold** text")) if err != nil { t.Fatalf("RenderHTML: %v", err) } if !strings.Contains(html, "bold") { t.Errorf("expected bold, got %q", html) } }
a1