Artifact f77821f0de5c21f1ada30a7b02aa66775d54ca04138578e875d6f652e5e0732e:
- File
internal/markup/markup_test.go
— part of check-in
[1d680372e5]
at
2026-08-16 13:44:53
on branch trunk
— markup: map fossil site-relative links; theme-neutral pikchr ink
Two bugs found by browsing real content on a phone.
Imported wiki/technote/forum bodies are full of fossil's own routes (libfossil's wiki alone: 500 /finfo, 211 /dir, 105 /timeline, 23 /doc). Quarry passed those hrefs through verbatim, so libfossil's home page offered a README.md link that went nowhere. Resolver grows ResolvePath: /doc/<ver>/<path> maps to quarry's file route, /info to the artifact, /timeline /wiki /forum /technote to their pages; a verb quarry has no page for drops the link and keeps the label, since a dead link is worse than plain text. file.get now lets zit resolve symbolic versions (trunk, tip, tags) instead of demanding a hash — that is what /doc links use, and zit is the authority on names. The href rewrite runs BEFORE reference resolution so the two passes never see each other's output.
Pikchr hardcodes rgb(0,0,0), which is invisible on quarry's dark theme — the DOM assertions all passed while the diagram was unreadable, and only a screenshot caught it. Its default ink now becomes currentColor so a diagram follows the page theme; author-specified colours are untouched. Fossil solves this with a server-side --dark-mode flag, which cannot work for a CSS-themed page. (user: dmestas, size: 8154) [annotate] [blame] [check-ins using]
package markup import ( "encoding/json" "os" "strings" "testing" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // --- conformance corpus (third_party/sanitizer-corpus, see ledger) --- type corpusCase struct { Name string `json:"name"` Input string `json:"input"` Deny []string `json:"deny"` Allow []string `json:"allow"` } type corpusFile struct { Source string `json:"source"` Cases []corpusCase `json:"cases"` } func TestConformanceCorpus(t *testing.T) { for _, name := range []string{"gitea", "forgejo"} { raw, err := os.ReadFile("../../third_party/sanitizer-corpus/" + name + ".json") if err != nil { t.Fatal(err) } var cf corpusFile if err := json.Unmarshal(raw, &cf); err != nil { t.Fatalf("%s: %v", name, err) } if len(cf.Cases) < 10 { t.Fatalf("%s: suspiciously small corpus (%d)", name, len(cf.Cases)) } for _, c := range cf.Cases { out := Sanitize(c.Input) for _, bad := range c.Deny { if strings.Contains(out, bad) { t.Errorf("%s/%s: output contains %q:\n%s", name, c.Name, bad, out) } } for _, good := range c.Allow { if !strings.Contains(out, good) { t.Errorf("%s/%s: output lost %q:\n%s", name, c.Name, good, out) } } } } } // --- rendering --- func TestRenderMarkdown(t *testing.T) { out, err := Render([]byte("# Title\n\nSome *emphasis* and `code`.\n\n- [ ] task\n"), nil) if err != nil { t.Fatal(err) } s := string(out) for _, want := range []string{"<h2", "<em>emphasis</em>", "<code>code</code>", `type="checkbox"`} { if !strings.Contains(s, want) { t.Errorf("missing %q in:\n%s", want, s) } } } func TestRenderStripsRawHTMLAttacks(t *testing.T) { out, err := Render([]byte("hello <script>alert(1)</script> <a href=\"javascript:x\" onclick=\"y\">link</a>\n"), nil) if err != nil { t.Fatal(err) } s := string(out) for _, bad := range []string{"<script", "javascript:", "onclick"} { if strings.Contains(s, bad) { t.Errorf("attack survived rendering: %q in\n%s", bad, s) } } } func TestRenderFencedCodeHighlights(t *testing.T) { out, err := Render([]byte("```go\npackage main\n```\n"), nil) if err != nil { t.Fatal(err) } s := string(out) // Chroma emits classed spans; the sanitizer must keep them and the // keyword must be inside one. if !strings.Contains(s, `<span class="kn">package</span>`) && !strings.Contains(s, `<span class="k">package</span>`) { t.Fatalf("fenced code not highlighted:\n%s", s) } } func TestRenderFencedCodeUnknownLanguageStaysPlain(t *testing.T) { out, err := Render([]byte("```zzznotalang\nhello\n```\n"), nil) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), "hello") { t.Fatalf("code content lost:\n%s", out) } } func TestRenderTable(t *testing.T) { out, err := Render([]byte("| a | b |\n|---|---|\n| 1 | 2 |\n"), nil) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), "<table>") { t.Fatalf("GFM table lost:\n%s", out) } } // --- resolver seam --- type fakeResolver struct{} func (fakeResolver) ResolveHash(prefix string) string { if strings.HasPrefix(prefix, "abc123") { return "/checkin/" + prefix } return "" } func (fakeResolver) ResolveWiki(name string) string { if name == "Design" { return "/wiki/Design" } return "" } func TestResolveWikiRef(t *testing.T) { out, err := Render([]byte("See [[Design]] for details.\n"), fakeResolver{}) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), `<a href="/wiki/Design">Design</a>`) { t.Fatalf("wiki ref not resolved:\n%s", out) } } func TestResolveUnknownWikiStaysText(t *testing.T) { out, err := Render([]byte("See [[Nowhere]].\n"), fakeResolver{}) if err != nil { t.Fatal(err) } if strings.Contains(string(out), "<a") { t.Fatalf("unresolvable ref linked:\n%s", out) } if !strings.Contains(string(out), "[[Nowhere]]") { t.Fatalf("unresolved ref text lost:\n%s", out) } } func TestResolveHashRef(t *testing.T) { out, err := Render([]byte("Fixed in abc123def456.\n"), fakeResolver{}) if err != nil { t.Fatal(err) } if !strings.Contains(string(out), `<a href="/checkin/abc123def456">abc123def456</a>`) { t.Fatalf("hash ref not resolved:\n%s", out) } } func TestResolveSkipsCodeSpans(t *testing.T) { out, err := Render([]byte("`abc123def456` and [[Design]] in code: `[[Design]]`\n"), fakeResolver{}) if err != nil { t.Fatal(err) } s := string(out) if strings.Contains(s, `<code><a`) || strings.Contains(s, "code><a") { t.Fatalf("resolved inside code span:\n%s", s) } if !strings.Contains(s, `<a href="/wiki/Design">Design</a>`) { t.Fatalf("prose ref should still resolve:\n%s", s) } } func TestResolverOutputStillSafe(t *testing.T) { // A hostile resolver URL must not smuggle scripts — Render output // re-enters pages as trusted HTML, so the seam is load-bearing. out, err := Render([]byte("[[Design]]\n"), evilResolver{}) if err != nil { t.Fatal(err) } if strings.Contains(string(out), "<script") { t.Fatalf("resolver injected markup:\n%s", out) } } type evilResolver struct{} func (evilResolver) ResolveHash(string) string { return "" } func (evilResolver) ResolveWiki(string) string { return `"><script>alert(1)</script>` } // --- fuzz --- func FuzzSanitize(f *testing.F) { f.Add(`<a href="javascript:alert(1)" onmouseover=x>y</a>`) f.Add("<scrİpt>hi</scrİpt>") f.Add(`<img src=x onerror=alert(1)>`) f.Add(`<code class="language-go ui modal">x</code>`) f.Add("plain **markdown** text") f.Fuzz(func(t *testing.T, in string) { // The oracle parses the output: dangerous constructs must not // exist as MARKUP. (Substring checks false-positive on inputs // that are plain text like "onmouseover=0".) body := &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body} nodes, err := html.ParseFragment(strings.NewReader(Sanitize(in)), body) if err != nil { t.Fatalf("output unparseable for %q: %v", in, err) } var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode { switch n.Data { case "script", "style", "iframe", "object", "embed", "form": t.Fatalf("policy leaked <%s> for input %q", n.Data, in) } for _, a := range n.Attr { if strings.HasPrefix(strings.ToLower(a.Key), "on") { t.Fatalf("policy leaked %s= on <%s> for input %q", a.Key, n.Data, in) } if a.Key == "href" || a.Key == "src" { v := strings.ToLower(strings.TrimSpace(a.Val)) if strings.HasPrefix(v, "javascript:") || strings.HasPrefix(v, "vbscript:") || strings.HasPrefix(v, "data:") { t.Fatalf("policy leaked %s=%q for input %q", a.Key, a.Val, in) } } } } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } for _, n := range nodes { walk(n) } }) } func TestHeadingsDemoted(t *testing.T) { // Ticket 110: the page template owns h1; content headings shift down. out, err := Render([]byte("# Top\n\n## Sub\n\n###### Deep\n"), nil) if err != nil { t.Fatal(err) } s := string(out) if strings.Contains(s, "<h1") { t.Fatalf("content h1 survived:\n%s", s) } for _, want := range []string{"<h2", "<h3", "<h6"} { if !strings.Contains(s, want) { t.Fatalf("missing %s (h6 must cap, not vanish):\n%s", want, s) } } } // GFM completeness: footnotes and heading anchors must survive the // sanitizer (enabling a goldmark extension is only half the job — the // policy has to admit the attributes it emits). func TestFootnotesAndHeadingIDs(t *testing.T) { out, err := Render([]byte("## A Heading Here\n\nText with a note.[^1]\n\n[^1]: The note body.\n"), nil) if err != nil { t.Fatal(err) } got := string(out) if !strings.Contains(got, `id="a-heading-here"`) { t.Fatalf("heading anchor missing or stripped:\n%s", got) } if !strings.Contains(got, "The note body") || !strings.Contains(got, "footnotes") { t.Fatalf("footnote missing or stripped:\n%s", got) } if !strings.Contains(got, "<sup") { t.Fatalf("footnote reference marker stripped:\n%s", got) } } func (fakeResolver) ResolvePath(string) string { return "" } func (evilResolver) ResolvePath(string) string { return "" }