Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Fuzz the path validators and the Markdown sanitizer

5fce878d41da32448803f3a4f4d42c7841fba331 · Keenan Nemetz · 10d ago · parent d9f5a930

Fuzz the path validators and the Markdown sanitizer

Two pieces of untrusted text decide where a file is written and what a browser
runs: the validators that turn a request path, an artifact glob, or a site
directory into a place under a root, and the markdown boundary where goldmark and
bluemonday turn a pushed body into HTML a page trusts.

The sanitizer assertions parse the output with an HTML parser rather than
searching the string, because the difference is the whole question: a script tag
rendered as text is a text node, and hand-written <script> looks the same to a
substring search. The test asks what the browser will do: no script-shaped
element, no on* attribute, and no scheme outside http, https, mailto.

The path targets state the guarantee the way the caller uses it -- joined to a
root, the result stays under that root -- and pin that cleaning is idempotent, so
one page cannot become two cache keys.

cleanPath's comment claimed dot segments were refused; they are resolved, and the
comment now says so, which is what the property pins.

Closes #98.

server/web/markdown_fuzz_test.go +133

@@ -0,0 +1,133 @@
1 package web
2
3 import (
4 "strings"
5 "testing"
6
7 "golang.org/x/net/html"
8 )
9
10 // The attributes that carry a destination. A scheme the browser will run is a
11 // click away from being script in this origin, so these are what the sanitizer
12 // has to have rewritten or removed.
13 var urlAttrs = map[string]bool{
14 "href": true, "src": true, "action": true, "formaction": true,
15 "poster": true, "data": true, "xlink:href": true,
16 }
17
18 // The elements that stop being text. bluemonday's UGC policy should remove all of
19 // them; the test is here so that loosening the policy fails rather than ships.
20 var unsafeElems = map[string]bool{
21 "script": true, "iframe": true, "object": true, "embed": true,
22 "form": true, "style": true, "link": true, "meta": true, "base": true,
23 }
24
25 var safeSchemes = map[string]bool{"": true, "http": true, "https": true, "mailto": true}
26
27 // FuzzRenderMarkdown drives the boundary where untrusted text becomes HTML:
28 // goldmark renders it and bluemonday sanitises it, and the result is trusted
29 // enough to put in a page.
30 //
31 // The assertions parse the output instead of searching the string, which is the
32 // difference that matters: `<script>` as text is a text node and harmless, while
33 // `&lt;script&gt;` written by hand looks the same to a substring search. Parsing
34 // asks what the browser will do.
35 //
36 // Both document shapes are checked: a body with no place in a tree (an issue or a
37 // comment), and a README whose relative links are rewritten on the way out.
38 func FuzzRenderMarkdown(f *testing.F) {
39 for _, seed := range []string{
40 "plain text",
41 "<script>alert(1)</script>",
42 "<img src=x onerror=alert(1)>",
43 "<IMG SRC=x ONERROR=alert(1)>",
44 "[link](javascript:alert(1))",
45 "[link](JaVaScRiPt:alert(1))",
46 "[link](data:text/html,<script>alert(1)</script>)",
47 "<a href=\"jav&#x09;ascript:alert(1)\">x</a>",
48 "[link](vbscript:msgbox)",
49 "<!-- comment --><script>x</script>",
50 "```\n<script>alert(1)</script>\n```\n",
51 "&lt;script&gt;alert(1)&lt;/script&gt;",
52 "<svg><script>alert(1)</script></svg>",
53 "<iframe src=\"https://example.invalid\"></iframe>",
54 "<style>body{background:url(javascript:alert(1))}</style>",
55 "| a | b |\n|---|---|\n| 1 | 2 |\n",
56 "[relative](docs/other.md#heading) and ![img](pic.png)",
57 "[climb](../../../../etc/passwd)",
58 "[abs](//example.invalid/x)",
59 "# heading\n\n- item\n\n> quote\n",
60 "",
61 } {
62 f.Add(seed)
63 }
64 f.Fuzz(func(t *testing.T, src string) {
65 w := &Web{}
66 for _, doc := range []treeDoc{
67 {Repo: "demo"}, // an issue body: no tree
68 {Repo: "demo", Ref: "main", Path: "docs/x.md"}, // a README: links rewritten
69 } {
70 out := string(w.renderTreeMarkdown(doc, src))
71 root, err := html.Parse(strings.NewReader(out))
72 if err != nil {
73 t.Fatalf("rendered HTML does not parse: %v\n%s", err, out)
74 }
75 checkHTML(t, root, out, src)
76 }
77 })
78 }
79
80 func checkHTML(t *testing.T, n *html.Node, out, src string) {
81 t.Helper()
82 if n.Type == html.ElementNode {
83 name := strings.ToLower(n.Data)
84 if unsafeElems[name] {
85 t.Fatalf("%q rendered a <%s> element:\n%s", src, name, out)
86 }
87 for _, attr := range n.Attr {
88 key := strings.ToLower(attr.Key)
89 // An event handler is script that runs on an event, whatever the tag.
90 if strings.HasPrefix(key, "on") {
91 t.Fatalf("%q rendered a %s attribute:\n%s", src, attr.Key, out)
92 }
93 value := strings.ToLower(strings.TrimSpace(attr.Val))
94 switch {
95 case urlAttrs[key]:
96 // A scheme is the part before the first colon, unless the colon is
97 // inside a path or query. Anything not on the list is a scheme a
98 // browser may act on.
99 if scheme, ok := urlScheme(value); ok && !safeSchemes[scheme] {
100 t.Fatalf("%q rendered %s=%q:\n%s", src, attr.Key, attr.Val, out)
101 }
102 case key == "srcset" || key == "style":
103 // These hold more than one value, so the check is for the schemes
104 // that matter rather than for a parse.
105 for _, bad := range []string{"javascript:", "vbscript:", "expression("} {
106 if strings.Contains(value, bad) {
107 t.Fatalf("%q rendered %s=%q:\n%s", src, attr.Key, attr.Val, out)
108 }
109 }
110 }
111 }
112 }
113 for child := n.FirstChild; child != nil; child = child.NextSibling {
114 checkHTML(t, child, out, src)
115 }
116 }
117
118 // urlScheme reports the scheme of a link destination, and whether there is one.
119 func urlScheme(value string) (string, bool) {
120 value = strings.TrimLeft(value, " \t\r\n")
121 i := strings.IndexByte(value, ':')
122 if i <= 0 {
123 return "", false
124 }
125 scheme := value[:i]
126 for _, r := range scheme {
127 if !(r == '+' || r == '-' || r == '.' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
128 // A colon that is not part of a scheme is part of a path, e.g. "a:b.md".
129 return "", false
130 }
131 }
132 return scheme, scheme != ""
133 }