fix(activitypub): harden object normalization

This commit is contained in:
Lain Soykaf 2026-07-26 12:35:09 +04:00
commit ce5c1b4c15
No known key found for this signature in database
11 changed files with 315 additions and 65 deletions

View file

@ -0,0 +1 @@
Accept more real-world ActivityPub object shapes collected by fedidev.fun

View file

@ -79,9 +79,11 @@ defmodule Pleroma.Object.Containment do
def contain_origin(id, %{"actor" => _actor} = params) do
id_uri = URI.parse(id)
actor_uri = URI.parse(get_actor(params))
compare_uris(actor_uri, id_uri)
case get_actor(params) do
actor when is_binary(actor) -> compare_uris(URI.parse(actor), id_uri)
_ -> :error
end
end
def contain_origin(id, %{"attributedTo" => actor} = params),

View file

@ -12,6 +12,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
require Pleroma.Constants
@primary_key false
@tag_types ~w[Mention Hashtag Emoji Link]
embedded_schema do
# Common
field(:type, :string)
@ -49,7 +51,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :href])
end
def changeset(struct, %{"type" => "Hashtag", "name" => name} = data) do
def changeset(struct, %{"type" => "Hashtag", "name" => name} = data) when is_binary(name) do
name = String.downcase(name)
data = Map.put(data, "name", name)
@ -58,7 +60,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :name])
end
def changeset(struct, %{"type" => "Emoji"} = data) do
def changeset(struct, %{"type" => "Hashtag"} = data) do
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
def changeset(struct, %{"type" => "Emoji", "name" => name} = data) when is_binary(name) do
data =
data
|> Map.put("name", String.trim(data["name"], ":"))
@ -84,6 +92,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
end
end
def changeset(struct, %{"type" => "Emoji"} = data) do
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
def changeset(struct, %{"type" => "Link"} = data) do
struct
|> cast(data, [:type, :name, :mediaType, :href])
@ -91,6 +105,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :href, :mediaType])
end
def changeset(struct, %{"type" => types} = data) when is_list(types) do
changeset(struct, infer_type(data))
end
def changeset(struct, %{"type" => _} = data) do
struct
|> cast(data, [])
@ -98,7 +116,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
end
def changeset(struct, data) when is_map(data) do
data = infer_type(data)
data = normalize(data)
if Map.has_key?(data, "type") do
changeset(struct, data)
@ -109,6 +127,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
end
end
def normalize(data) when is_map(data), do: infer_type(data)
defp infer_type(%{"type" => types} = data) when is_list(types) do
type = Enum.find(types, &(&1 in @tag_types)) || Enum.find(types, &is_binary/1)
if type, do: Map.put(data, "type", type), else: Map.delete(data, "type")
end
defp infer_type(%{"type" => _} = data), do: data
defp infer_type(%{"name" => "#" <> _} = data), do: Map.put(data, "type", "Hashtag")
defp infer_type(%{"name" => "@" <> _} = data), do: Map.put(data, "type", "Mention")

View file

@ -19,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
@ -28,6 +29,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
require Pleroma.Constants
@supported_types Pleroma.Constants.activity_types() ++
Pleroma.Constants.object_types() ++ Pleroma.Constants.actor_types()
@doc """
Modifies an incoming AP object (mastodon format) to our internal format.
"""
@ -301,58 +305,66 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
attachments =
Enum.map(attachment, fn data ->
url =
cond do
is_list(data["url"]) -> List.first(data["url"])
is_map(data["url"]) -> data["url"]
true -> nil
end
Enum.map(attachment, fn
data when is_map(data) ->
url =
cond do
is_list(data["url"]) -> List.first(data["url"])
is_map(data["url"]) -> data["url"]
true -> nil
end
media_type =
cond do
is_map(url) && is_bitstring(url["mediaType"]) &&
url["mediaType"] =~ Pleroma.Constants.mime_regex() ->
url["mediaType"]
url_data = if is_map(url), do: url, else: %{}
is_bitstring(data["mediaType"]) && data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
data["mediaType"]
media_type =
cond do
is_bitstring(url_data["mediaType"]) &&
url_data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
url_data["mediaType"]
is_bitstring(data["mimeType"]) && data["mimeType"] =~ Pleroma.Constants.mime_regex() ->
data["mimeType"]
is_bitstring(data["mediaType"]) &&
data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
data["mediaType"]
true ->
nil
end
is_bitstring(data["mimeType"]) && data["mimeType"] =~ Pleroma.Constants.mime_regex() ->
data["mimeType"]
href =
cond do
is_map(url) && is_binary(url["href"]) -> url["href"]
is_binary(data["url"]) -> data["url"]
is_binary(data["href"]) -> data["href"]
true -> nil
end
true ->
nil
end
href =
cond do
is_binary(url) -> url
is_binary(url_data["href"]) -> url_data["href"]
is_binary(data["url"]) -> data["url"]
is_binary(data["href"]) -> data["href"]
true -> nil
end
if href && valid_http_url?(href) do
attachment_url =
%{
"href" => href,
"type" => Map.get(url_data, "type", "Link")
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("width", url_data["width"] || data["width"])
|> Maps.put_if_present("height", url_data["height"] || data["height"])
if href && valid_http_url?(href) do
attachment_url =
%{
"href" => href,
"type" => Map.get(url || %{}, "type", "Link")
"url" => [attachment_url],
"type" => data["type"] || "Document"
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("width", (url || %{})["width"] || data["width"])
|> Maps.put_if_present("height", (url || %{})["height"] || data["height"])
|> Maps.put_if_present("name", data["name"])
|> Maps.put_if_present("blurhash", data["blurhash"])
else
nil
end
%{
"url" => [attachment_url],
"type" => data["type"] || "Document"
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("name", data["name"])
|> Maps.put_if_present("blurhash", data["blurhash"])
else
_ ->
nil
end
end)
|> Enum.filter(& &1)
@ -404,14 +416,23 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
defp emoji_icon_url(_), do: nil
defp valid_emoji_icon_url(icon) do
case emoji_icon_url(icon) do
url when is_binary(url) -> if valid_http_url?(url), do: url
_ -> nil
end
end
def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
emoji =
tags
|> Enum.filter(fn data -> is_map(data) and data["type"] == "Emoji" and data["icon"] end)
|> Enum.filter(fn data ->
is_map(data) and data["type"] == "Emoji" and is_binary(data["name"]) and data["icon"]
end)
|> Enum.reduce(%{}, fn data, mapping ->
name = String.trim(data["name"], ":")
case emoji_icon_url(data["icon"]) do
case valid_emoji_icon_url(data["icon"]) do
url when is_binary(url) -> Map.put(mapping, name, url)
_ -> mapping
end
@ -420,10 +441,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
Map.put(object, "emoji", emoji)
end
def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
name = String.trim(tag["name"], ":")
def fix_emoji(%{"tag" => %{"type" => "Emoji", "name" => name} = tag} = object)
when is_binary(name) do
name = String.trim(name, ":")
with url when is_binary(url) <- emoji_icon_url(tag["icon"]) do
with url when is_binary(url) <- valid_emoji_icon_url(tag["icon"]) do
Map.put(object, "emoji", %{name => url})
else
_ -> object
@ -435,7 +457,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
tags =
tag
|> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
|> Enum.filter(fn data ->
is_map(data) and data["type"] == "Hashtag" and is_binary(data["name"])
end)
|> Enum.map(fn
%{"name" => "#" <> hashtag} -> String.downcase(hashtag)
%{"name" => hashtag} -> String.downcase(hashtag)
@ -502,10 +526,32 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
|> handle_incoming_normalized(options)
end
defp fix_type_as_string(%{"type" => [type | _]} = data) when is_binary(type),
do: Map.put(data, "type", type)
defp fix_type_as_string(data) do
data
|> normalize_type()
|> normalize_tag_types()
end
defp fix_type_as_string(data), do: data
defp normalize_type(%{"type" => types} = data) when is_list(types) do
type = Enum.find(types, &(&1 in @supported_types)) || Enum.find(types, &is_binary/1)
if type, do: Map.put(data, "type", type), else: data
end
defp normalize_type(data), do: data
defp normalize_tag_types(%{"tag" => tags} = data) when is_list(tags) do
Map.put(data, "tag", Enum.map(tags, &normalize_tag_type/1))
end
defp normalize_tag_types(%{"tag" => tag} = data) when is_map(tag) do
Map.put(data, "tag", normalize_tag_type(tag))
end
defp normalize_tag_types(data), do: data
defp normalize_tag_type(tag) when is_map(tag), do: TagValidator.normalize(tag)
defp normalize_tag_type(tag), do: tag
# Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
# with nil ID.

View file

@ -27,6 +27,11 @@ defmodule Pleroma.Object.ContainmentTest do
})
end
test "rejects an empty actor list without crashing" do
assert :error ==
Containment.contain_origin("https://example.com/objects/1", %{"actor" => []})
end
test "contain_origin_from_id() catches obvious spoofing attempts" do
data = %{
"id" => "http://example.com/~alyssa/activities/1234.json"

View file

@ -36,8 +36,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FedidevFunAttachmentsTest do
end
end
defp ingest_fixture!(rel_path) do
data = rel_path |> File.read!() |> Jason.decode!()
defp ingest_data!(data) do
ensure_remote_actor!(data["actor"])
assert {:ok, activity} = Transmogrifier.handle_incoming(data)
@ -46,6 +45,13 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FedidevFunAttachmentsTest do
{activity, object}
end
defp ingest_fixture!(rel_path) do
rel_path
|> File.read!()
|> Jason.decode!()
|> ingest_data!()
end
test "drops non-http(s) attachment href" do
{_activity, object} = ingest_fixture!("test/fixtures/fedidev.fun/attachment_payto_link.json")
assert object.data["attachment"] == []
@ -66,4 +72,27 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FedidevFunAttachmentsTest do
assert href == "https://fedidev.fun/images/007.png"
end
test "drops malformed attachment list entries" do
data = File.read!("test/fixtures/fedidev.fun/attachment_url_list.json") |> Jason.decode!()
data = update_in(data, ["object", "attachment"], &["malformed" | List.wrap(&1)])
{_activity, object} = ingest_data!(data)
assert length(object.data["attachment"]) == 1
end
test "accepts a URI string in an attachment url list" do
data = File.read!("test/fixtures/fedidev.fun/attachment_url_list.json") |> Jason.decode!()
data =
put_in(data, ["object", "attachment", "url"], [
"https://fedidev.fun/images/007.png"
])
{_activity, object} = ingest_data!(data)
assert [%{"url" => [%{"href" => "https://fedidev.fun/images/007.png"}]}] =
object.data["attachment"]
end
end

View file

@ -36,8 +36,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FedidevFunEmojiTest do
end
end
defp ingest_fixture!(rel_path) do
data = rel_path |> File.read!() |> Jason.decode!()
defp ingest_data!(data) do
ensure_remote_actor!(data["actor"])
assert {:ok, activity} = Transmogrifier.handle_incoming(data)
@ -46,6 +45,13 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FedidevFunEmojiTest do
{activity, object}
end
defp ingest_fixture!(rel_path) do
rel_path
|> File.read!()
|> Jason.decode!()
|> ingest_data!()
end
test "ingests Emoji tag with string icon" do
{_activity, object} = ingest_fixture!("test/fixtures/fedidev.fun/emoji_icon_string.json")
assert object.data["emoji"]["cow"] == "https://fedidev.fun/static/cow.png"
@ -62,4 +68,36 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FedidevFunEmojiTest do
{_activity, object} = ingest_fixture!("test/fixtures/fedidev.fun/emoji_missing_icon.json")
assert object.data["emoji"] == %{}
end
test "ignores Emoji tag missing a string name" do
data = File.read!("test/fixtures/fedidev.fun/emoji_icon_string.json") |> Jason.decode!()
data = put_in(data, ["object", "tag", Access.at(0), "name"], nil)
{_activity, object} = ingest_data!(data)
assert object.data["emoji"] == %{}
end
test "ignores Emoji tag with a non-http icon" do
data = File.read!("test/fixtures/fedidev.fun/emoji_icon_string.json") |> Jason.decode!()
data = put_in(data, ["object", "tag", Access.at(0), "icon"], "file:///tmp/cow.png")
{_activity, object} = ingest_data!(data)
assert object.data["emoji"] == %{}
end
test "normalizes a list-typed Emoji tag before building the emoji map" do
data = File.read!("test/fixtures/fedidev.fun/emoji_icon_string.json") |> Jason.decode!()
data =
put_in(data, ["object", "tag", Access.at(0), "type"], [
"https://example.com/ns#CustomTag",
"Emoji"
])
{_activity, object} = ingest_data!(data)
assert object.data["emoji"]["cow"] == "https://fedidev.fun/static/cow.png"
end
end

View file

@ -50,4 +50,15 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FedidevFunObjectTypeListTest do
{_activity, object} = ingest_fixture!("test/fixtures/fedidev.fun/object_type_list.json")
assert object.data["type"] == "Note"
end
test "selects a supported type after an extension type" do
data = File.read!("test/fixtures/fedidev.fun/object_type_list.json") |> Jason.decode!()
data = put_in(data, ["object", "type"], ["https://example.com/ns#CustomType", "Note"])
ensure_remote_actor!(data["actor"])
assert {:ok, activity} = Transmogrifier.handle_incoming(data)
object = Object.normalize(activity.data["object"], fetch: false)
assert object.data["type"] == "Note"
end
end

View file

@ -62,4 +62,51 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FedidevFunTagInferenceTest do
_ -> false
end)
end
test "normalizes a list-typed tag type" do
data =
File.read!("test/fixtures/fedidev.fun/tag_hashtag_missing_type.json") |> Jason.decode!()
data = put_in(data, ["object", "tag", Access.at(0), "type"], ["Hashtag"])
ensure_remote_actor!(data["actor"])
assert {:ok, activity} = Transmogrifier.handle_incoming(data)
object = Object.normalize(activity.data["object"], fetch: false)
assert "test" in Object.hashtags(object)
end
test "ignores a list-typed Hashtag without a string name" do
data =
File.read!("test/fixtures/fedidev.fun/tag_hashtag_missing_type.json") |> Jason.decode!()
data = put_in(data, ["object", "tag", Access.at(0), "type"], ["Hashtag"])
data = put_in(data, ["object", "tag", Access.at(0), "name"], nil)
ensure_remote_actor!(data["actor"])
assert {:ok, activity} = Transmogrifier.handle_incoming(data)
object = Object.normalize(activity.data["object"], fetch: false)
assert Object.hashtags(object) == []
end
test "infers a Link before deriving quoteUrl" do
data =
File.read!("test/fixtures/fedidev.fun/tag_hashtag_missing_type.json") |> Jason.decode!()
data =
put_in(data, ["object", "tag"], [
%{
"href" => "https://fedidev.fun/assets/note1.jsonap",
"mediaType" => "application/activity+json"
}
])
ensure_remote_actor!(data["actor"])
assert {:ok, activity} = Transmogrifier.handle_incoming(data)
object = Object.normalize(activity.data["object"], fetch: false)
assert object.data["quoteUrl"] == "https://fedidev.fun/assets/note1.jsonap"
end
end

View file

@ -671,13 +671,21 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do
test "returns object with emoji when object contains list tags" do
assert Transmogrifier.fix_emoji(%{
"tag" => [
%{"type" => "Emoji", "name" => ":bib:", "icon" => %{"url" => "/test"}},
%{
"type" => "Emoji",
"name" => ":bib:",
"icon" => %{"url" => "https://example.com/test"}
},
%{"type" => "Hashtag"}
]
}) == %{
"emoji" => %{"bib" => "/test"},
"emoji" => %{"bib" => "https://example.com/test"},
"tag" => [
%{"icon" => %{"url" => "/test"}, "name" => ":bib:", "type" => "Emoji"},
%{
"icon" => %{"url" => "https://example.com/test"},
"name" => ":bib:",
"type" => "Emoji"
},
%{"type" => "Hashtag"}
]
}
@ -685,10 +693,28 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do
test "returns object with emoji when object contains map tag" do
assert Transmogrifier.fix_emoji(%{
"tag" => %{"type" => "Emoji", "name" => ":bib:", "icon" => %{"url" => "/test"}}
"tag" => %{
"type" => "Emoji",
"name" => ":bib:",
"icon" => %{"url" => "https://example.com/test"}
}
}) == %{
"emoji" => %{"bib" => "/test"},
"tag" => %{"icon" => %{"url" => "/test"}, "name" => ":bib:", "type" => "Emoji"}
"emoji" => %{"bib" => "https://example.com/test"},
"tag" => %{
"icon" => %{"url" => "https://example.com/test"},
"name" => ":bib:",
"type" => "Emoji"
}
}
end
end
describe "fix_tag/1" do
test "ignores malformed Hashtag names and tag entries" do
tag = %{"type" => "Hashtag", "name" => 123}
assert Transmogrifier.fix_tag(%{"tag" => [tag, "malformed"]}) == %{
"tag" => [tag, "malformed"]
}
end
end

View file

@ -50,6 +50,25 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do
assert user.bio == "<p>Some bio</p>"
end
test "it selects an actor type after an extension type" do
user = insert(:user, local: false)
update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!()
object =
update_data["object"]
|> Map.put("actor", user.ap_id)
|> Map.put("id", user.ap_id)
|> Map.put("type", ["https://example.com/ns#CustomActor", "Person"])
update_data =
update_data
|> Map.put("actor", user.ap_id)
|> Map.put("object", object)
assert {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(update_data)
assert User.get_cached_by_ap_id(user.ap_id).name == "gargle"
end
test "it works with alsoKnownAs" do
%{ap_id: actor} = insert(:user, local: false)