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 | // `<script>` 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	ascript:alert(1)\">x</a>", | ||
| 48 | "[link](vbscript:msgbox)", | ||
| 49 | "<!-- comment --><script>x</script>", | ||
| 50 | "```\n<script>alert(1)</script>\n```\n", | ||
| 51 | "<script>alert(1)</script>", | ||
| 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 ", | ||
| 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 | } | ||