neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Markdown: XSS payload suite over everything rendered raw
845c94b · Gabriel Morell · 2026-10-20 20:31
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	script:alert(1))", | |
| 43 | + "[x](javascript:alert(1))", | |
| 44 | + "[x](javascript:alert(1))", | |
| 45 | + "[x](vbscript:msgbox(1))", | |
| 46 | + "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", | |
| 47 | + "[x](file:///etc/passwd)", | |
| 48 | + ")", | |
| 49 | + "</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