fix(html): check link schemes the way browsers read them

FastSanitize checks the scheme with URI.parse/1, which finds none in
java<TAB>script:alert(1). Browsers remove tabs and newlines and leading
control characters first and run it as a javascript: link. The scrubbers
now use Pleroma.HTML.URIAttributes, which normalizes the URL the same
way before the scheme check.
This commit is contained in:
Lain Soykaf 2026-10-22 16:30:38 +04:00
commit 1be50ed28b
No known key found for this signature in database
6 changed files with 95 additions and 5 deletions

View file

@ -0,0 +1 @@
Scrubbers: remove links and images whose scheme is hidden with tabs, newlines or leading control characters (for example java<TAB>script:), which browsers still run as javascript:. Custom scrubbers should use Pleroma.HTML.URIAttributes.allow_tag_with_uri_attributes instead of the FastSanitize macro

View file

@ -0,0 +1,40 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTML.URIAttributes do
@moduledoc """
URI attribute rules for the scrubbers.
Works like `FastSanitize.Sanitizer.Meta.allow_tag_with_uri_attributes/3`, but
checks the scheme the way a browser reads the URL. Browsers remove tabs and
newlines anywhere and control characters and spaces at the start, so
`java\\tscript:` is a `javascript:` link to them, while `URI.parse/1` finds no
scheme and FastSanitize lets it through.
"""
defmacro allow_tag_with_uri_attributes(tag, attrs, valid_schemes) do
for attr <- attrs do
quote do
def scrub_attribute(unquote(tag), {unquote(attr), "&" <> _}), do: nil
def scrub_attribute(unquote(tag), {unquote(attr), uri} = attribute) do
if Pleroma.HTML.URIAttributes.valid_scheme?(uri, unquote(valid_schemes)),
do: attribute
end
end
end
end
@spec valid_scheme?(String.t(), [String.t()]) :: boolean()
def valid_scheme?(uri, valid_schemes) do
scheme =
uri
|> String.replace(~r/[\t\n\r]/, "")
|> String.replace(~r/\A[\x00-\x20]+/, "")
|> URI.parse()
|> Map.get(:scheme)
is_nil(scheme) or scheme in valid_schemes
end
end

View file

@ -6,6 +6,7 @@ defmodule Pleroma.HTML.Scrubber.Default do
@doc "The default HTML scrubbing policy: no " @doc "The default HTML scrubbing policy: no "
require FastSanitize.Sanitizer.Meta require FastSanitize.Sanitizer.Meta
require Pleroma.HTML.URIAttributes
alias FastSanitize.Sanitizer.Meta alias FastSanitize.Sanitizer.Meta
# credo:disable-for-previous-line # credo:disable-for-previous-line
@ -15,7 +16,11 @@ defmodule Pleroma.HTML.Scrubber.Default do
Meta.strip_comments() Meta.strip_comments()
Meta.allow_tag_with_uri_attributes(:a, ["href", "data-user", "data-tag"], @valid_schemes) Pleroma.HTML.URIAttributes.allow_tag_with_uri_attributes(
:a,
["href", "data-user", "data-tag"],
@valid_schemes
)
Meta.allow_tag_with_this_attribute_values(:a, "class", [ Meta.allow_tag_with_this_attribute_values(:a, "class", [
"hashtag", "hashtag",
@ -145,7 +150,7 @@ defmodule Pleroma.HTML.Scrubber.Default do
Meta.allow_tag_with_this_attribute_values(:img, "class", ["emoji"]) Meta.allow_tag_with_this_attribute_values(:img, "class", ["emoji"])
# restrict img tags to http/https only, because of MediaProxy. # restrict img tags to http/https only, because of MediaProxy.
Meta.allow_tag_with_uri_attributes(:img, ["src"], ["http", "https"]) Pleroma.HTML.URIAttributes.allow_tag_with_uri_attributes(:img, ["src"], ["http", "https"])
Meta.allow_tag_with_these_attributes(:img, [ Meta.allow_tag_with_these_attributes(:img, [
"width", "width",

View file

@ -10,12 +10,13 @@ defmodule Pleroma.HTML.Scrubber.LinksOnly do
@valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], []) @valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], [])
require FastSanitize.Sanitizer.Meta require FastSanitize.Sanitizer.Meta
require Pleroma.HTML.URIAttributes
alias FastSanitize.Sanitizer.Meta alias FastSanitize.Sanitizer.Meta
Meta.strip_comments() Meta.strip_comments()
# links # links
Meta.allow_tag_with_uri_attributes(:a, ["href"], @valid_schemes) Pleroma.HTML.URIAttributes.allow_tag_with_uri_attributes(:a, ["href"], @valid_schemes)
Meta.allow_tag_with_this_attribute_values(:a, "rel", [ Meta.allow_tag_with_this_attribute_values(:a, "rel", [
"tag", "tag",

View file

@ -11,12 +11,17 @@ defmodule Pleroma.HTML.Scrubber.TwitterText do
@valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], []) @valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], [])
require FastSanitize.Sanitizer.Meta require FastSanitize.Sanitizer.Meta
require Pleroma.HTML.URIAttributes
alias FastSanitize.Sanitizer.Meta alias FastSanitize.Sanitizer.Meta
Meta.strip_comments() Meta.strip_comments()
# links # links
Meta.allow_tag_with_uri_attributes(:a, ["href", "data-user", "data-tag"], @valid_schemes) Pleroma.HTML.URIAttributes.allow_tag_with_uri_attributes(
:a,
["href", "data-user", "data-tag"],
@valid_schemes
)
Meta.allow_tag_with_this_attribute_values(:a, "class", [ Meta.allow_tag_with_this_attribute_values(:a, "class", [
"hashtag", "hashtag",
@ -49,7 +54,7 @@ defmodule Pleroma.HTML.Scrubber.TwitterText do
Meta.allow_tag_with_this_attribute_values(:img, "class", ["emoji"]) Meta.allow_tag_with_this_attribute_values(:img, "class", ["emoji"])
# restrict img tags to http/https only, because of MediaProxy. # restrict img tags to http/https only, because of MediaProxy.
Meta.allow_tag_with_uri_attributes(:img, ["src"], ["http", "https"]) Pleroma.HTML.URIAttributes.allow_tag_with_uri_attributes(:img, ["src"], ["http", "https"])
Meta.allow_tag_with_these_attributes(:img, [ Meta.allow_tag_with_these_attributes(:img, [
"width", "width",

View file

@ -141,6 +141,44 @@ defmodule Pleroma.HTMLTest do
end end
end end
describe "link schemes as a browser sees them" do
# Browsers remove tabs and newlines in URLs and control characters and
# spaces at the start, so these are javascript: links to them.
test "removes javascript: links hidden with control characters" do
for href <- [
"java\tscript:alert(1)",
"java&#x09;script:alert(1)",
"java\nscript:alert(1)",
"&#x0d;javascript:alert(1)",
" javascript:alert(1)",
"\u0001javascript:alert(1)"
],
scrubber <- [
Pleroma.HTML.Scrubber.Default,
Pleroma.HTML.Scrubber.TwitterText,
Pleroma.HTML.Scrubber.LinksOnly
] do
html = HTML.filter_tags(~s(<a href="#{href}">x</a>), scrubber)
refute html =~ "href", "#{inspect(href)} survived #{inspect(scrubber)}: #{html}"
end
end
test "keeps normal links" do
for href <- ["https://example.org/a?b=c", "/relative/path", "#fragment"] do
assert HTML.filter_tags(~s(<a href="#{href}">x</a>), Pleroma.HTML.Scrubber.Default) ==
~s(<a href="#{href}">x</a>)
end
end
test "removes images with hidden schemes" do
html =
HTML.filter_tags(~s|<img src="java\tscript:alert(1)"/>|, Pleroma.HTML.Scrubber.Default)
refute html =~ "src"
end
end
describe "default scrubber" do describe "default scrubber" do
test "normalizes HTML as expected" do test "normalizes HTML as expected" do
expected = """ expected = """