defmodule GitGud.MarkdownXssTest do
@moduledoc """
Known XSS payloads through everything that ends up in a page via
`Phoenix.HTML.raw/1`: `GitGud.Markdown.to_html/2` (comments, issues,
wikis, READMEs) and `GitGud.SyntaxHighlight.highlight/2` (source and
diff lines).
The check is on the parsed output, not on strings: no element that
runs or loads anything, no event-handler attribute, no URL with a
scheme that executes. Payloads may still appear as escaped text.
"""
use GitGud.DataCase, async: false
alias GitGud.Cache
alias GitGud.Markdown
alias GitGud.SyntaxHighlight
import GitGud.ForgeFixtures
@markdown_payloads [
# raw HTML
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"<svg/onload=alert(1)>",
"<iframe src=\"javascript:alert(1)\"></iframe>",
"<a href=\"javascript:alert(1)\">x</a>",
"<details open ontoggle=alert(1)>x</details>",
"<style>body{display:none}</style>",
"<form action=\"https://evil.example\"><input name=p></form>",
"<meta http-equiv=\"refresh\" content=\"0;url=https://evil.example\">",
"<base href=\"https://evil.example/\">",
"<object data=\"javascript:alert(1)\"></object>",
"<embed src=\"javascript:alert(1)\">",
"<math><mi xlink:href=\"javascript:alert(1)\">x</mi></math>",
"<!--><script>alert(1)</script>-->",
"<<script>script>alert(1)<</script>/script>",
"x <img src=x onerror=alert(1)> inline",
# link and image URLs
"[x](javascript:alert(1))",
"[x](JaVaScRiPt:alert(1))",
"[x]( javascript:alert(1))",
"[x](java	script:alert(1))",
"[x](javascript:alert(1))",
"[x](javascript:alert(1))",
"[x](vbscript:msgbox(1))",
"[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
"[x](file:///etc/passwd)",
")",
"</script>)",
"[x][1]\n\n[1]: javascript:alert(1)",
"<javascript:alert(1)>",
"[x](https://ok.example \"\\\" onmouseover=\\\"alert(1)\")",
"[x](https://ok.example\" onmouseover=\"alert(1))",
"www.example.com/\" onmouseover=\"alert(1)",
"https://example.com/<script>alert(1)</script>",
# GFM extensions
"```\" onmouseover=\"alert(1)\nx\n```",
"```js\" ><script>alert(1)</script>\nx\n```",
"# <img src=x onerror=alert(1)>",
"# \"><script>alert(1)</script>",
"> [!NOTE]\n> <script>alert(1)</script>",
"| <script>alert(1)</script> |\n|---|\n| <img src=x onerror=alert(1)> |",
"- [x] <script>alert(1)</script>",
"x[^<script>]\n\n[^<script>]: <img src=x onerror=alert(1)>",
"~~<script>alert(1)</script>~~"
]
@source_payloads [
{"page.html", "<script>alert(1)</script>\n<img src=x onerror=alert(1)>"},
{"x.js", "document.write('<script>alert(1)</script>')"},
{"README", "<svg/onload=alert(1)>"},
{"weird.\"><script>alert(1)</script>", "<script>alert(1)</script>"}
]
# Elements a renderer of user content must never emit.
@forbidden_tags ~w(script iframe frame frameset object embed svg math style link meta base
form textarea button select applet template noscript)
setup do
Cache.delete_all()
:ok
end
for {payload, i} <- Enum.with_index(@markdown_payloads) do
test "markdown payload #{i}: #{inspect(String.slice(payload, 0, 50))}" do
assert_safe(Markdown.to_html(unquote(payload)), unquote(payload))
end
end
for {{name, code}, i} <- Enum.with_index(@source_payloads) do
test "source payload #{i} (#{inspect(name)})" do
assert_safe(
SyntaxHighlight.highlight(unquote(code), filename: unquote(name)),
unquote(code)
)
end
end
test "an issue title can't break out of the cross-reference link around it" do
{user, repo} = repository_fixture()
for title <- [
"x\" onmouseover=\"alert(1)",
"x\\\" onmouseover=\\\"alert(1)",
"x\") [click](javascript:alert(1)",
"<script>alert(1)</script>",
"x\" ><img src=x onerror=alert(1)>"
] do
issue = issue_fixture(repo, user, %{"title" => title})
html = Markdown.to_html("see ##{issue.number}", repo: repo)
assert_safe(html, title)
end
end
test "the check itself catches unsafe HTML" do
for bad <- [
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"<a href=\" JaVa\tScRiPt:alert(1)\">x</a>",
"<img src=\"data:text/html,x\">",
"<input name=p>",
"<span style=\"background:url(javascript:alert(1))\">x</span>"
] do
assert_raise ExUnit.AssertionError, fn -> assert_safe(bad, bad) end
end
assert_safe(~s(<img src="data:image/png;base64,AAAA"><a href="/r/x">x</a>), "ok")
end
defp assert_safe(html, payload) do
html = to_string(html)
doc = LazyHTML.from_fragment(html)
elements = LazyHTML.query(doc, "*")
for {tag, attrs} <- Enum.zip(LazyHTML.tag(elements), LazyHTML.attributes(elements)) do
refute tag in @forbidden_tags,
"<#{tag}> in the output for #{inspect(payload)}:\n#{html}"
if tag == "input" do
assert {"type", "checkbox"} in attrs and List.keymember?(attrs, "disabled", 0),
"an input that isn't a disabled task checkbox, for #{inspect(payload)}:\n#{html}"
end
for {name, value} <- attrs do
refute String.starts_with?(String.downcase(name), "on"),
"event handler #{name} for #{inspect(payload)}:\n#{html}"
if name in ~w(href src action formaction xlink:href data poster srcset) do
refute dangerous_url?(tag, value),
"#{name}=#{inspect(value)} for #{inspect(payload)}:\n#{html}"
end
if name == "style" do
refute value =~ ~r/url\(|expression\(|javascript:/i,
"style=#{inspect(value)} for #{inspect(payload)}:\n#{html}"
end
end
end
end
# A browser ignores whitespace and control characters inside a scheme,
# so `java\tscript:` is still javascript.
defp dangerous_url?(tag, value) do
url = value |> String.replace(~r/[\x00-\x20]/, "") |> String.downcase()
cond do
String.starts_with?(url, ["javascript:", "vbscript:", "file:"]) -> true
tag == "img" and url =~ ~r/^data:image\/(png|gif|jpe?g|webp);/ -> false
String.starts_with?(url, "data:") -> true
true -> false
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
6.3 KiB · text
History
b868907