6.3 KiB · text History 845c94b
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&#09;script:alert(1))",
    "[x](&#106;avascript:alert(1))",
    "[x](javascript&colon;alert(1))",
    "[x](vbscript:msgbox(1))",
    "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
    "[x](file:///etc/passwd)",
    "![x](javascript:alert(1))",
    "![x](data:text/html,<script>alert(1)</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