Markdown: XSS payload suite over everything rendered raw

845c94b · Gabriel Morell · 2026-10-20 20:31

3 files +173 -0
Message
{commit_body(@commit)}

Files changed

added test/git_gud/markdown_xss_test.exs
+173 −0
@@ -0,0 +1,173 @@
1 +defmodule GitGud.MarkdownXssTest do
2 + @moduledoc """
3 + Known XSS payloads through everything that ends up in a page via
4 + `Phoenix.HTML.raw/1`: `GitGud.Markdown.to_html/2` (comments, issues,
5 + wikis, READMEs) and `GitGud.SyntaxHighlight.highlight/2` (source and
6 + diff lines).
7 +
8 + The check is on the parsed output, not on strings: no element that
9 + runs or loads anything, no event-handler attribute, no URL with a
10 + scheme that executes. Payloads may still appear as escaped text.
11 + """
12 + use GitGud.DataCase, async: false
13 +
14 + alias GitGud.Cache
15 + alias GitGud.Markdown
16 + alias GitGud.SyntaxHighlight
17 +
18 + import GitGud.ForgeFixtures
19 +
20 + @markdown_payloads [
21 + # raw HTML
22 + "<script>alert(1)</script>",
23 + "<img src=x onerror=alert(1)>",
24 + "<svg/onload=alert(1)>",
25 + "<iframe src=\"javascript:alert(1)\"></iframe>",
26 + "<a href=\"javascript:alert(1)\">x</a>",
27 + "<details open ontoggle=alert(1)>x</details>",
28 + "<style>body{display:none}</style>",
29 + "<form action=\"https://evil.example\"><input name=p></form>",
30 + "<meta http-equiv=\"refresh\" content=\"0;url=https://evil.example\">",
31 + "<base href=\"https://evil.example/\">",
32 + "<object data=\"javascript:alert(1)\"></object>",
33 + "<embed src=\"javascript:alert(1)\">",
34 + "<math><mi xlink:href=\"javascript:alert(1)\">x</mi></math>",
35 + "<!--><script>alert(1)</script>-->",
36 + "<<script>script>alert(1)<</script>/script>",
37 + "x <img src=x onerror=alert(1)> inline",
38 + # link and image URLs
39 + "[x](javascript:alert(1))",
40 + "[x](JaVaScRiPt:alert(1))",
41 + "[x]( javascript:alert(1))",
42 + "[x](java&#09;script:alert(1))",
43 + "[x](&#106;avascript:alert(1))",
44 + "[x](javascript&colon;alert(1))",
45 + "[x](vbscript:msgbox(1))",
46 + "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
47 + "[x](file:///etc/passwd)",
48 + "![x](javascript:alert(1))",
49 + "![x](data:text/html,<script>alert(1)</script>)",
50 + "[x][1]\n\n[1]: javascript:alert(1)",
51 + "<javascript:alert(1)>",
52 + "[x](https://ok.example \"\\\" onmouseover=\\\"alert(1)\")",
53 + "[x](https://ok.example\" onmouseover=\"alert(1))",
54 + "www.example.com/\" onmouseover=\"alert(1)",
55 + "https://example.com/<script>alert(1)</script>",
56 + # GFM extensions
57 + "```\" onmouseover=\"alert(1)\nx\n```",
58 + "```js\" ><script>alert(1)</script>\nx\n```",
59 + "# <img src=x onerror=alert(1)>",
60 + "# \"><script>alert(1)</script>",
61 + "> [!NOTE]\n> <script>alert(1)</script>",
62 + "| <script>alert(1)</script> |\n|---|\n| <img src=x onerror=alert(1)> |",
63 + "- [x] <script>alert(1)</script>",
64 + "x[^<script>]\n\n[^<script>]: <img src=x onerror=alert(1)>",
65 + "~~<script>alert(1)</script>~~"
66 + ]
67 +
68 + @source_payloads [
69 + {"page.html", "<script>alert(1)</script>\n<img src=x onerror=alert(1)>"},
70 + {"x.js", "document.write('<script>alert(1)</script>')"},
71 + {"README", "<svg/onload=alert(1)>"},
72 + {"weird.\"><script>alert(1)</script>", "<script>alert(1)</script>"}
73 + ]
74 +
75 + # Elements a renderer of user content must never emit.
76 + @forbidden_tags ~w(script iframe frame frameset object embed svg math style link meta base
77 + form textarea button select applet template noscript)
78 +
79 + setup do
80 + Cache.delete_all()
81 + :ok
82 + end
83 +
84 + for {payload, i} <- Enum.with_index(@markdown_payloads) do
85 + test "markdown payload #{i}: #{inspect(String.slice(payload, 0, 50))}" do
86 + assert_safe(Markdown.to_html(unquote(payload)), unquote(payload))
87 + end
88 + end
89 +
90 + for {{name, code}, i} <- Enum.with_index(@source_payloads) do
91 + test "source payload #{i} (#{inspect(name)})" do
92 + assert_safe(
93 + SyntaxHighlight.highlight(unquote(code), filename: unquote(name)),
94 + unquote(code)
95 + )
96 + end
97 + end
98 +
99 + test "an issue title can't break out of the cross-reference link around it" do
100 + {user, repo} = repository_fixture()
101 +
102 + for title <- [
103 + "x\" onmouseover=\"alert(1)",
104 + "x\\\" onmouseover=\\\"alert(1)",
105 + "x\") [click](javascript:alert(1)",
106 + "<script>alert(1)</script>",
107 + "x\" ><img src=x onerror=alert(1)>"
108 + ] do
109 + issue = issue_fixture(repo, user, %{"title" => title})
110 + html = Markdown.to_html("see ##{issue.number}", repo: repo)
111 + assert_safe(html, title)
112 + end
113 + end
114 +
115 + test "the check itself catches unsafe HTML" do
116 + for bad <- [
117 + "<script>alert(1)</script>",
118 + "<img src=x onerror=alert(1)>",
119 + "<a href=\" JaVa\tScRiPt:alert(1)\">x</a>",
120 + "<img src=\"data:text/html,x\">",
121 + "<input name=p>",
122 + "<span style=\"background:url(javascript:alert(1))\">x</span>"
123 + ] do
124 + assert_raise ExUnit.AssertionError, fn -> assert_safe(bad, bad) end
125 + end
126 +
127 + assert_safe(~s(<img src="data:image/png;base64,AAAA"><a href="/r/x">x</a>), "ok")
128 + end
129 +
130 + defp assert_safe(html, payload) do
131 + html = to_string(html)
132 + doc = LazyHTML.from_fragment(html)
133 + elements = LazyHTML.query(doc, "*")
134 +
135 + for {tag, attrs} <- Enum.zip(LazyHTML.tag(elements), LazyHTML.attributes(elements)) do
136 + refute tag in @forbidden_tags,
137 + "<#{tag}> in the output for #{inspect(payload)}:\n#{html}"
138 +
139 + if tag == "input" do
140 + assert {"type", "checkbox"} in attrs and List.keymember?(attrs, "disabled", 0),
141 + "an input that isn't a disabled task checkbox, for #{inspect(payload)}:\n#{html}"
142 + end
143 +
144 + for {name, value} <- attrs do
145 + refute String.starts_with?(String.downcase(name), "on"),
146 + "event handler #{name} for #{inspect(payload)}:\n#{html}"
147 +
148 + if name in ~w(href src action formaction xlink:href data poster srcset) do
149 + refute dangerous_url?(tag, value),
150 + "#{name}=#{inspect(value)} for #{inspect(payload)}:\n#{html}"
151 + end
152 +
153 + if name == "style" do
154 + refute value =~ ~r/url\(|expression\(|javascript:/i,
155 + "style=#{inspect(value)} for #{inspect(payload)}:\n#{html}"
156 + end
157 + end
158 + end
159 + end
160 +
161 + # A browser ignores whitespace and control characters inside a scheme,
162 + # so `java\tscript:` is still javascript.
163 + defp dangerous_url?(tag, value) do
164 + url = value |> String.replace(~r/[\x00-\x20]/, "") |> String.downcase()
165 +
166 + cond do
167 + String.starts_with?(url, ["javascript:", "vbscript:", "file:"]) -> true
168 + tag == "img" and url =~ ~r/^data:image\/(png|gif|jpe?g|webp);/ -> false
169 + String.starts_with?(url, "data:") -> true
170 + true -> false
171 + end
172 + end
173 +end

Parents: 3db9f38