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:
commit
ecd20c7587
18 changed files with 626 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue