Merge pull request 'Federation: validate actor hosts and fetched object ids' (#8034) from lambadalambda/pleroma:fix/actor-containment into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/8034
This commit is contained in:
lain 2026-10-28 07:19:42 +00:00
commit ecd20c7587
18 changed files with 626 additions and 27 deletions

View file

@ -44,6 +44,9 @@ defmodule Pleroma.Object.Containment do
id
end
# remote data can carry any shape here; callers treat nil as "no actor"
def get_actor(_), do: nil
def get_object(%{"object" => id}) when is_binary(id) do
id
end
@ -108,8 +111,46 @@ defmodule Pleroma.Object.Containment do
def contain_origin_from_id(_id, _data), do: :error
@doc """
Checks that a fetched document's id is the URL it was fetched from
(after redirects).
Fragments, a trailing slash, the case of the host and differences
that our URL encoding removes before a request are ignored.
Documents without an id never match.
Based on Akkoma's `contain_id_to_fetch/2`.
"""
def contain_id_to_fetch(url, %{"id" => id}) when is_binary(url) and is_binary(id) do
if normalize_uri(url) == normalize_uri(id), do: :ok, else: :error
end
def contain_id_to_fetch(_url, _data), do: :error
defp normalize_uri(uri) do
uri = uri |> Pleroma.Utils.URIEncoding.encode_url() |> URI.parse()
%URI{
uri
| fragment: nil,
host: uri.host && String.downcase(uri.host, :ascii),
path: uri.path && String.replace_suffix(uri.path, "/", "")
}
|> URI.to_string()
end
def contain_child(%{"object" => %{"id" => id, "attributedTo" => _} = object}),
do: contain_origin(id, object)
def contain_child(_), do: :ok
@doc "Checks that two URIs are on the same host. URIs without a host never match."
def same_origin(id, other_id) when is_binary(id) and is_binary(other_id) do
case {URI.parse(id), URI.parse(other_id)} do
{%URI{host: host}, %URI{host: host}} when is_binary(host) and host != "" -> :ok
_ -> :error
end
end
def same_origin(_id, _other_id), do: :error
end

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Object.Fetcher do
alias Pleroma.Web.ActivityPub.InternalFetchActor
alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.Federator
@ -46,9 +47,14 @@ defmodule Pleroma.Object.Fetcher do
end
end
def refetch_object(%Object{data: %{"id" => id}} = object) do
# The stored id is canonical, so the refetch must not redirect, and the
# remote must not swap in another object or author.
# Based on Akkoma 9061d148b and fee57eb37.
def refetch_object(%Object{data: %{"id" => id} = data} = object) do
with {:local, false} <- {:local, Object.local?(object)},
{:ok, new_data} <- fetch_and_contain_remote_object_from_id(id),
{:ok, new_data} <- fetch_and_contain(id, true),
{:id, true} <- {:id, new_data["id"] == id},
{:actor, true} <- {:actor, actor_of(new_data) == actor_of(data)},
{:ok, object} <- reinject_object(object, new_data) do
{:ok, object}
else
@ -57,6 +63,8 @@ defmodule Pleroma.Object.Fetcher do
end
end
defp actor_of(data), do: CommonFixes.fix_actor(data)["actor"]
@typep fetcher_errors ::
:error | :reject | :allowed_depth | :fetch | :containment | :transmogrifier
@ -140,17 +148,39 @@ defmodule Pleroma.Object.Fetcher do
def fetch_and_contain_remote_object_from_id(%{"id" => id}),
do: fetch_and_contain_remote_object_from_id(id)
def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do
def fetch_and_contain_remote_object_from_id(id) when is_binary(id),
do: fetch_and_contain(id, false)
def fetch_and_contain_remote_object_from_id(_id),
do: {:error, "id must be a string"}
# The fetched document's id must be the URL it came from. Display URLs
# (e.g. Mastodon's /@user/123) serve the object under another id, so
# if the id differs we refetch once from that id, which has to be on the
# same host, must not redirect, and must then match exactly.
# Based on Akkoma 8684964c5 and 940792f8b.
defp fetch_and_contain(id, canonical?) do
Logger.debug("Fetching object #{id} via AP")
with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")},
{_, true} <- {:mrf, MRF.id_filter(id)},
{_, :ok} <- {:local_fetch, Containment.contain_local_fetch(id)},
{:ok, body} <- get_object(id),
{:ok, final_url, body} <- get_object(id),
{_, true} <- {:redirect, not canonical? or same_url?(final_url, id)},
{:ok, data} <- safe_json_decode(body),
:ok <- Containment.contain_origin_from_id(id, data) do
:ok <- Containment.contain_origin_from_id(id, data),
{_, _, :ok} <- {:id, data, Containment.contain_id_to_fetch(final_url, data)} do
{:ok, data}
else
{:id, %{"id" => canonical_id}, :error} when is_binary(canonical_id) and not canonical? ->
fetch_and_contain(canonical_id, true)
{:id, _, :error} ->
{:error, :id_mismatch}
{:redirect, false} ->
{:error, :canonical_id_redirect}
{:scheme, _} ->
{:error, "Unsupported URI scheme"}
@ -168,8 +198,7 @@ defmodule Pleroma.Object.Fetcher do
end
end
def fetch_and_contain_remote_object_from_id(_id),
do: {:error, "id must be a string"}
defp same_url?(url, other), do: Containment.contain_id_to_fetch(url, %{"id" => other}) == :ok
defp check_crossdomain_redirect(final_host, _original_url) when is_nil(final_host) do
{:cross_domain_redirect, false}
@ -195,7 +224,7 @@ defmodule Pleroma.Object.Fetcher do
with {:cross_domain_redirect, false} <- check_crossdomain_redirect(remote_host, id),
{_, content_type} <- List.keyfind(headers, "content-type", 0),
{:ok, _media_type} <- verify_content_type(content_type) do
{:ok, body}
{:ok, if(final_url in [nil, ""], do: id, else: final_url), body}
else
{:cross_domain_redirect, true} ->
{:error, {:cross_domain_redirect, true}}
@ -209,7 +238,7 @@ defmodule Pleroma.Object.Fetcher do
case List.keyfind(headers, "content-type", 0) do
{_, content_type} ->
case verify_content_type(content_type) do
{:ok, _} -> {:ok, body}
{:ok, _} -> {:ok, id, body}
error -> error
end

View file

@ -21,6 +21,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
alias Pleroma.Upload
alias Pleroma.User
alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.ObjectValidators.UserValidator
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.Streamer
alias Pleroma.Web.WebFinger
@ -1924,6 +1925,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
def user_data_from_user_object(data, additional \\ []) do
with {:ok, data} <- MRF.filter(data),
{:ok, data, _} <- UserValidator.validate(data, []),
{:ok, data} <- object_to_user_data(data, additional) do
{:ok, data}
else
@ -2053,11 +2055,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
def enqueue_pin_fetches(_), do: nil
def make_user_from_ap_id(ap_id, additional \\ []) do
user = User.get_cached_by_ap_id(ap_id)
with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do
enqueue_pin_fetches(data)
# the fetched actor's id differs from ap_id when it was fetched from a display URL
user = User.get_cached_by_ap_id(data.ap_id)
if user do
user
|> User.remote_user_changeset(data)

View file

@ -11,7 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
@behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating
import Pleroma.Constants, only: [activity_types: 0, object_types: 0]
import Pleroma.Constants, only: [activity_types: 0, actor_types: 0, object_types: 0]
alias Pleroma.Activity
alias Pleroma.EctoType.ActivityPub.ObjectValidators
@ -37,6 +37,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.UserValidator
@impl true
def validate(object, meta)
@ -226,7 +227,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
end
def validate(%{"type" => type} = object, meta) when type == "Update" do
with {:ok, object} <-
with :ok <- validate_updated_actor(object, meta),
{:ok, object} <-
object
|> UpdateValidator.cast_and_validate(meta)
|> Ecto.Changeset.apply_action(:insert) do
@ -247,6 +249,19 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
def validate(o, m), do: {:error, {:validator_not_set, {o, m}}}
defp validate_updated_actor(%{"object" => %{"type" => type} = actor}, meta)
when type in actor_types() do
with {_, false} <- {:local, Access.get(meta, :local, false)},
{:ok, _, _} <- UserValidator.validate(actor, meta) do
:ok
else
{:local, true} -> :ok
error -> error
end
end
defp validate_updated_actor(_, _), do: :ok
def cast_and_apply_and_stringify_with_history(object) do
do_separate_with_history(object, fn object ->
with {:ok, object_data} <- cast_and_apply(object),

View file

@ -0,0 +1,53 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidator do
@moduledoc """
Checks that a remote actor document only points at its own host.
We deliver to the inbox and shared inbox, use the collections for
addressing and visibility checks, and map signature key ids back to
the actor. If an actor could point any of these at another host, it
could redirect our signed deliveries or claim another actor's
collections. Optional fields are only checked when present; null and
empty strings count as missing.
Based on Akkoma's UserValidator.
"""
@behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating
alias Pleroma.Object.Containment
@collections ["inbox", "followers", "following", "featured"]
@impl true
def validate(%{"id" => id} = data, meta) when is_binary(id) do
fields =
Enum.map(@collections, &{&1, data[&1]}) ++
[
{"sharedInbox", get_in_map(data, ["endpoints", "sharedInbox"])},
{"publicKey", get_in_map(data, ["publicKey", "id"])}
]
case Enum.find(fields, fn {_, value} -> not same_origin?(id, value) end) do
nil -> {:ok, data, meta}
{field, _} -> {:error, "#{field} is not on the same host as the actor"}
end
end
def validate(_data, _meta), do: {:error, "Actor has no id"}
defp same_origin?(_id, value) when value in [nil, ""], do: true
defp same_origin?(id, %{"id" => value}), do: same_origin?(id, value)
defp same_origin?(id, value), do: Containment.same_origin(id, value) == :ok
defp get_in_map(data, [key | rest]) do
case data do
%{^key => value} when rest == [] -> value
%{^key => value} -> get_in_map(value, rest)
_ -> nil
end
end
end