Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85828606
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
197 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/changelog.d/host-header-verification.security b/changelog.d/host-header-verification.security
new file mode 100644
index 000000000..11a0784b2
--- /dev/null
+++ b/changelog.d/host-header-verification.security
@@ -0,0 +1 @@
+Ensure Host header is present and matches instance URI
diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
index 2bfff6968..3d1875599 100644
--- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex
@@ -1,643 +1,648 @@
# 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.ActivityPubController do
use Pleroma.Web, :controller
alias Pleroma.Activity
alias Pleroma.Delivery
alias Pleroma.Object
alias Pleroma.Object.Fetcher
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.InternalFetchActor
alias Pleroma.Web.ActivityPub.ObjectView
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.UserView
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.ControllerHelper
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Federator
alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug
alias Pleroma.Web.Plugs.FederatingPlug
require Logger
action_fallback(:errors)
@federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers]
@object_replies_known_param_keys ["page", "min_id", "max_id", "since_id", "limit"]
plug(FederatingPlug when action in @federating_only_actions)
plug(
EnsureAuthenticatedPlug,
[unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions
)
# Note: :following and :followers must be served even without authentication (as via :api)
plug(
EnsureAuthenticatedPlug
when action in [:read_inbox, :update_outbox, :whoami, :upload_media]
)
plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:upload_media])
plug(
Pleroma.Web.Plugs.Cache,
[query_params: false, tracking_fun: &__MODULE__.track_object_fetch/2]
when action in [:activity, :object]
)
plug(:log_inbox_metadata when action in [:inbox])
plug(:relay_active? when action in [:relay])
defp relay_active?(conn, _) do
if Pleroma.Config.get([:instance, :allow_relay]) do
conn
else
conn
|> render_error(:not_found, "not found")
|> halt()
end
end
def user(conn, %{"nickname" => nickname}) do
with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("user.json", %{user: user})
else
nil -> {:error, :not_found}
%{local: false} -> {:error, :not_found}
end
end
def object(%{assigns: assigns} = conn, _) do
with ap_id <- Endpoint.url() <> conn.request_path,
%Object{} = object <- Object.get_cached_by_ap_id(ap_id),
user <- Map.get(assigns, :user, nil),
{_, true} <- {:visible?, Visibility.visible_for_user?(object, user)} do
conn
|> maybe_skip_cache(user)
|> assign(:tracking_fun_data, object.id)
|> set_cache_ttl_for(object)
|> put_resp_content_type("application/activity+json")
|> put_view(ObjectView)
|> render("object.json", object: object)
else
{:visible?, false} -> {:error, :not_found}
nil -> {:error, :not_found}
end
end
def object_replies(%{assigns: assigns, query_params: params} = conn, _all_params) do
object_ap_id = conn.path_info |> Enum.reverse() |> tl() |> Enum.reverse()
object_ap_id = Endpoint.url() <> "/" <> Enum.join(object_ap_id, "/")
# Most other API params are converted to atoms by OpenAPISpex 3.x
# and therefore helper functions assume atoms. For consistency,
# also convert our params to atoms here.
params =
params
|> Map.take(@object_replies_known_param_keys)
|> Enum.into(%{}, fn {k, v} -> {String.to_existing_atom(k), v} end)
|> Map.put(:object_ap_id, object_ap_id)
|> Map.put(:order_asc, true)
|> Map.put(:conn, conn)
with %Object{} = object <- Object.get_cached_by_ap_id(object_ap_id),
user <- Map.get(assigns, :user, nil),
{_, true} <- {:visible?, Visibility.visible_for_user?(object, user)} do
conn
|> maybe_skip_cache(user)
|> set_cache_ttl_for(object)
|> put_resp_content_type("application/activity+json")
|> put_view(ObjectView)
|> render("object_replies.json", render_params: params)
else
{:visible?, false} -> {:error, :not_found}
nil -> {:error, :not_found}
end
end
def track_object_fetch(conn, nil), do: conn
def track_object_fetch(conn, object_id) do
with %{assigns: %{user: %User{id: user_id}}} <- conn do
Delivery.create(object_id, user_id)
end
conn
end
def activity(%{assigns: assigns} = conn, _) do
with ap_id <- Endpoint.url() <> conn.request_path,
%Activity{} = activity <- Activity.normalize(ap_id),
{_, true} <- {:local?, activity.local},
user <- Map.get(assigns, :user, nil),
{_, true} <- {:visible?, Visibility.visible_for_user?(activity, user)} do
conn
|> maybe_skip_cache(user)
|> maybe_set_tracking_data(activity)
|> set_cache_ttl_for(activity)
|> put_resp_content_type("application/activity+json")
|> put_view(ObjectView)
|> render("object.json", object: activity)
else
{:visible?, false} -> {:error, :not_found}
{:local?, false} -> {:error, :not_found}
nil -> {:error, :not_found}
end
end
defp maybe_set_tracking_data(conn, %Activity{data: %{"type" => "Create"}} = activity) do
object_id = Object.normalize(activity, fetch: false).id
assign(conn, :tracking_fun_data, object_id)
end
defp maybe_set_tracking_data(conn, _activity), do: conn
defp set_cache_ttl_for(conn, %Activity{object: object}) do
set_cache_ttl_for(conn, object)
end
defp set_cache_ttl_for(conn, entity) do
ttl =
case entity do
%Object{data: %{"type" => "Question"}} ->
Pleroma.Config.get([:web_cache_ttl, :activity_pub_question])
%Object{} ->
Pleroma.Config.get([:web_cache_ttl, :activity_pub])
_ ->
nil
end
assign(conn, :cache_ttl, ttl)
end
def maybe_skip_cache(conn, user) do
if user do
conn
|> assign(:skip_cache, true)
else
conn
end
end
# GET /relay/following
def relay_following(conn, _params) do
with %{halted: false} = conn <- FederatingPlug.call(conn, []) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("following.json", %{user: Relay.get_actor()})
end
end
def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do
with %User{} = user <- User.get_cached_by_nickname(nickname),
{:show_follows, true} <-
{:show_follows, (for_user && for_user == user) || !user.hide_follows} do
{page, _} = Integer.parse(page)
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("following.json", %{user: user, page: page, for: for_user})
else
{:show_follows, _} ->
conn
|> put_resp_content_type("application/activity+json")
|> send_resp(403, "")
end
end
def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_cached_by_nickname(nickname) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("following.json", %{user: user, for: for_user})
end
end
# GET /relay/followers
def relay_followers(conn, _params) do
with %{halted: false} = conn <- FederatingPlug.call(conn, []) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("followers.json", %{user: Relay.get_actor()})
end
end
def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do
with %User{} = user <- User.get_cached_by_nickname(nickname),
{:show_followers, true} <-
{:show_followers, (for_user && for_user == user) || !user.hide_followers} do
{page, _} = Integer.parse(page)
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("followers.json", %{user: user, page: page, for: for_user})
else
{:show_followers, _} ->
conn
|> put_resp_content_type("application/activity+json")
|> send_resp(403, "")
end
end
def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_cached_by_nickname(nickname) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("followers.json", %{user: user, for: for_user})
end
end
def outbox(
%{assigns: %{user: for_user}} = conn,
%{"nickname" => nickname, "page" => page?} = params
)
when page? in [true, "true"] do
with %User{} = user <- User.get_cached_by_nickname(nickname) do
# "include_poll_votes" is a hack because postgres generates inefficient
# queries when filtering by 'Answer', poll votes will be hidden by the
# visibility filter in this case anyway
params =
params
|> Map.drop(["nickname", "page"])
|> Map.put("include_poll_votes", true)
|> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end)
activities = ActivityPub.fetch_user_activities(user, for_user, params)
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("activity_collection_page.json", %{
activities: activities,
pagination: ControllerHelper.get_pagination_fields(conn, activities)
})
end
end
def outbox(conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_cached_by_nickname(nickname) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("activity_collection.json", %{iri: "#{user.ap_id}/outbox"})
end
end
- def inbox(%{assigns: %{valid_signature: true}} = conn, %{"nickname" => nickname} = params) do
+ def inbox(
+ %{
+ assigns: %{valid_signature: true, valid_host_header: true}
+ } = conn,
+ %{"nickname" => nickname} = params
+ ) do
with {:recipient_exists, %User{} = recipient} <-
{:recipient_exists, User.get_cached_by_nickname(nickname)},
{:sender_exists, {:ok, %User{} = actor}} <-
{:sender_exists, User.get_or_fetch_by_ap_id(params["actor"])},
{:recipient_active, true} <- {:recipient_active, recipient.is_active},
{:sender_active, true} <- {:sender_active, actor.is_active},
true <- Utils.recipient_in_message(recipient, actor, params),
params <- Utils.maybe_splice_recipient(recipient.ap_id, params) do
Federator.incoming_ap_doc(params)
json(conn, "ok")
else
{:recipient_exists, _} ->
conn
|> put_status(:not_found)
|> json("User does not exist")
{:sender_exists, _} ->
conn
|> put_status(:not_found)
|> json("Sender does not exist")
{:recipient_active, _} ->
conn
|> put_status(:not_found)
|> json("User deactivated")
{:sender_active, _} ->
conn
|> put_status(:not_found)
|> json("Sender deactivated")
_ ->
conn
|> put_status(:bad_request)
|> json("Invalid request.")
end
end
- def inbox(%{assigns: %{valid_signature: true}} = conn, params) do
+ def inbox(%{assigns: %{valid_signature: true, valid_host_header: true}} = conn, params) do
Federator.incoming_ap_doc(params)
json(conn, "ok")
end
def inbox(%{assigns: %{valid_signature: false}} = conn, params) do
Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,
params: params,
query_string: conn.query_string
})
json(conn, "ok")
end
# POST /relay/inbox -or- POST /internal/fetch/inbox
def inbox(conn, %{"type" => "Create"} = params) do
if FederatingPlug.federating?() do
post_inbox_relayed_create(conn, params)
else
conn
|> put_status(403)
|> json("Not federating")
end
end
def inbox(conn, _params) do
conn
|> put_status(:bad_request)
|> json("error, missing HTTP Signature")
end
defp post_inbox_relayed_create(conn, params) do
Logger.debug(
"Signature missing or not from author, relayed Create message, fetching object from source"
)
Fetcher.fetch_object_from_id(params["object"]["id"])
json(conn, "ok")
end
defp represent_service_actor(%User{} = user, conn) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("user.json", %{user: user})
end
defp represent_service_actor(nil, _), do: {:error, :not_found}
def relay(conn, _params) do
Relay.get_actor()
|> represent_service_actor(conn)
end
def internal_fetch(conn, _params) do
InternalFetchActor.get_actor()
|> represent_service_actor(conn)
end
@doc "Returns the authenticated user's ActivityPub User object or a 404 Not Found if non-authenticated"
def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("user.json", %{user: user})
end
def read_inbox(
%{assigns: %{user: %User{nickname: nickname} = user}} = conn,
%{"nickname" => nickname, "page" => page?} = params
)
when page? in [true, "true"] do
params =
params
|> Map.drop(["nickname", "page"])
|> Map.put("blocking_user", user)
|> Map.put("user", user)
|> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end)
activities =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
|> Enum.reverse()
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("activity_collection_page.json", %{
activities: activities,
pagination: ControllerHelper.get_pagination_fields(conn, activities)
})
end
def read_inbox(%{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{
"nickname" => nickname
}) do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)
|> render("activity_collection.json", %{iri: "#{user.ap_id}/inbox"})
end
def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{
"nickname" => nickname
}) do
err =
dgettext("errors", "can't read inbox of %{nickname} as %{as_nickname}",
nickname: nickname,
as_nickname: as_nickname
)
conn
|> put_status(:forbidden)
|> json(err)
end
defp fix_user_message(%User{ap_id: actor}, %{"type" => "Create", "object" => object} = activity)
when is_map(object) do
length =
[object["content"], object["summary"], object["name"]]
|> Enum.filter(&is_binary(&1))
|> Enum.join("")
|> String.length()
limit = Pleroma.Config.get([:instance, :limit])
if length < limit do
object =
object
|> Transmogrifier.strip_internal_fields()
|> Map.put("attributedTo", actor)
|> Map.put("actor", actor)
|> Map.put("id", Utils.generate_object_id())
{:ok, Map.put(activity, "object", object)}
else
{:error,
dgettext(
"errors",
"Character limit (%{limit} characters) exceeded, contains %{length} characters",
limit: limit,
length: length
)}
end
end
defp fix_user_message(
%User{ap_id: actor} = user,
%{"type" => "Delete", "object" => object} = activity
) do
with {_, %Object{data: object_data}} <- {:normalize, Object.normalize(object, fetch: false)},
{_, true} <- {:permission, user.is_moderator || actor == object_data["actor"]} do
{:ok, activity}
else
{:normalize, _} ->
{:error, "No such object found"}
{:permission, _} ->
{:forbidden, "You can't delete this object"}
end
end
defp fix_user_message(%User{}, activity) do
{:ok, activity}
end
# We currently lack a Flag ObjectValidator since both CommonAPI and Transmogrifier
# both send it straight to ActivityPub.flag and C2S currently has to go through
# the normal pipeline which requires an ObjectValidator.
# TODO: Add a Flag Activity ObjectValidator
defp check_allowed_action(_, %{"type" => "Flag"}) do
{:error, "Flag activities aren't currently supported in C2S"}
end
# It would respond with 201 and silently fail with:
# Could not decode featured collection at fetch #{user.ap_id} \
# {:error, "Trying to fetch local resource"}
defp check_allowed_action(%{ap_id: ap_id}, %{"type" => "Update", "object" => %{"id" => ap_id}}),
do: {:error, "Updating profile is not currently supported in C2S"}
defp check_allowed_action(_, activity), do: {:ok, activity}
defp validate_visibility(%User{} = user, %{"type" => type, "object" => object} = activity) do
with {_, %Object{} = normalized_object} <-
{:normalize, Object.normalize(object, fetch: false)},
{_, true} <- {:visibility, Visibility.visible_for_user?(normalized_object, user)} do
{:ok, activity}
else
{:normalize, _} ->
if type in ["Create", "Listen"] do
# Creating new object via C2S; user is local and authenticated
# via the :authenticate Plug pipeline.
{:ok, activity}
else
{:error, "No such object found"}
end
{:visibility, _} ->
{:forbidden, "You can't interact with this object"}
end
end
def update_outbox(
%{assigns: %{user: %User{nickname: nickname, ap_id: actor} = user}} = conn,
%{"nickname" => nickname} = params
) do
params =
params
|> Map.drop(["nickname"])
|> Map.put("id", Utils.generate_activity_id())
|> Map.put("actor", actor)
with {:ok, params} <- fix_user_message(user, params),
{:ok, params} <- check_allowed_action(user, params),
{:ok, params} <- validate_visibility(user, params),
{:ok, activity, _} <- Pipeline.common_pipeline(params, local: true),
%Activity{data: activity_data} <- Activity.normalize(activity) do
conn
|> put_status(:created)
|> put_resp_header("location", activity_data["id"])
|> json(activity_data)
else
{:forbidden, message} ->
conn
|> put_status(:forbidden)
|> json(message)
{:error, message} when is_binary(message) ->
conn
|> put_status(:bad_request)
|> json(message)
e ->
Logger.warning(fn -> "AP C2S: #{inspect(e)}" end)
conn
|> put_status(:bad_request)
|> json("Bad Request")
end
end
def update_outbox(%{assigns: %{user: %User{} = user}} = conn, %{"nickname" => nickname}) do
err =
dgettext("errors", "can't update outbox of %{nickname} as %{as_nickname}",
nickname: nickname,
as_nickname: user.nickname
)
conn
|> put_status(:forbidden)
|> json(err)
end
defp errors(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> json(dgettext("errors", "Not found"))
end
defp errors(conn, _e) do
conn
|> put_status(:internal_server_error)
|> json(dgettext("errors", "error"))
end
defp log_inbox_metadata(%{params: %{"actor" => actor, "type" => type}} = conn, _) do
Logger.metadata(actor: actor, type: type)
conn
end
defp log_inbox_metadata(conn, _), do: conn
def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = data) do
with {:ok, object} <-
ActivityPub.upload(
file,
actor: User.ap_id(user),
description: Map.get(data, "description")
) do
Logger.debug(inspect(object))
conn
|> put_status(:created)
|> json(object.data)
end
end
def pinned(conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_cached_by_nickname(nickname) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(UserView.render("featured.json", %{user: user}))
end
end
end
diff --git a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex
new file mode 100644
index 000000000..ab2129f9e
--- /dev/null
+++ b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex
@@ -0,0 +1,63 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlug do
+ @moduledoc "Ensures Host header matches instance"
+
+ alias Pleroma.Web.Endpoint
+
+ import Plug.Conn
+
+ def init(options), do: options
+
+ @spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t()
+ def call(%Plug.Conn{assigns: %{valid_signature: true}} = conn, _opts) do
+ # Host header is scheme-less, URI.parse needs the //
+ host_header = get_req_header(conn, "host")
+ host_uri = URI.parse("//#{host_header}")
+ instance_uri = URI.parse(Endpoint.url())
+
+ case host_header do
+ [host] ->
+ cond do
+ host == "" ->
+ resp(conn, 400, "Host header not provided") |> halt()
+
+ true ->
+ if host_matches?(host_uri, instance_uri),
+ do: assign(conn, :valid_host_header, true),
+ else: resp(conn, 400, "Host header does not match this instance") |> halt()
+ end
+
+ [_head | _rest] ->
+ conn
+ |> resp(400, "More than one Host header provided")
+ |> halt()
+
+ [] ->
+ conn
+ |> resp(400, "Host header not provided")
+ |> halt()
+ end
+ end
+
+ # Host header may not be provided, but signature verification failed anyway
+ def call(conn, _opts), do: conn
+
+ defp case_insensitive_compare(checked, authority) do
+ String.downcase(checked) == String.downcase(authority)
+ end
+
+ # Host header did not provide port
+ # Host header is scheme-less, URI.parse does not provide default port
+ defp host_matches?(%URI{host: req_host, port: nil}, %URI{host: instance_host}),
+ do: case_insensitive_compare(req_host, instance_host)
+
+ # Host header provided a port
+ # Any port specified in the Endpoint url configuration is valid here
+ defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host, port: port}),
+ do: case_insensitive_compare(req_host, instance_host)
+
+ defp host_matches?(_, _), do: false
+end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index c91ca8c97..813c5c482 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -1,1119 +1,1120 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Router do
use Pleroma.Web, :router
import Phoenix.LiveDashboard.Router
import Oban.Web.Router
pipeline :accepts_html do
plug(:accepts, ["html"])
end
pipeline :accepts_html_xml do
plug(:accepts, ["html", "xml", "rss", "atom"])
end
pipeline :accepts_html_json do
plug(:accepts, ["html", "activity+json", "json"])
end
pipeline :accepts_html_xml_json do
plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"])
end
pipeline :accepts_xml_rss_atom do
plug(:accepts, ["xml", "rss", "atom"])
end
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :oauth do
plug(:fetch_session)
plug(Pleroma.Web.Plugs.OAuthPlug)
plug(Pleroma.Web.Plugs.UserEnabledPlug)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
end
# Note: expects _user_ authentication (user-unbound app-bound tokens don't qualify)
pipeline :expect_user_authentication do
plug(Pleroma.Web.Plugs.ExpectAuthenticatedCheckPlug)
end
# Note: expects public instance or _user_ authentication (user-unbound tokens don't qualify)
pipeline :expect_public_instance_or_user_authentication do
plug(Pleroma.Web.Plugs.ExpectPublicOrAuthenticatedCheckPlug)
end
pipeline :authenticate do
plug(Pleroma.Web.Plugs.OAuthPlug)
plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug)
plug(Pleroma.Web.Plugs.UserFetcherPlug)
plug(Pleroma.Web.Plugs.AuthenticationPlug)
end
pipeline :after_auth do
plug(Pleroma.Web.Plugs.UserEnabledPlug)
plug(Pleroma.Web.Plugs.SetUserSessionIdPlug)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
plug(Pleroma.Web.Plugs.UserTrackingPlug)
end
pipeline :base_api do
plug(:accepts, ["json"])
plug(:fetch_session)
plug(:authenticate)
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :no_auth_or_privacy_expectations_api do
plug(:base_api)
plug(:after_auth)
plug(Pleroma.Web.Plugs.IdempotencyPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
# Pipeline for app-related endpoints (no user auth checks — app-bound tokens must be supported)
pipeline :app_api do
plug(:no_auth_or_privacy_expectations_api)
end
pipeline :api do
plug(:expect_public_instance_or_user_authentication)
plug(:no_auth_or_privacy_expectations_api)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :authenticated_api do
plug(:expect_user_authentication)
plug(:no_auth_or_privacy_expectations_api)
plug(Pleroma.Web.Plugs.EnsureAuthenticatedPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :admin_api do
plug(:expect_user_authentication)
plug(:base_api)
plug(Pleroma.Web.Plugs.AdminSecretAuthenticationPlug)
plug(:after_auth)
plug(Pleroma.Web.Plugs.EnsureAuthenticatedPlug)
plug(Pleroma.Web.Plugs.UserIsStaffPlug)
plug(Pleroma.Web.Plugs.IdempotencyPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :require_admin do
plug(Pleroma.Web.Plugs.UserIsAdminPlug)
end
pipeline :require_privileged_role_users_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_delete)
end
pipeline :require_privileged_role_users_manage_credentials do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_credentials)
end
pipeline :require_privileged_role_messages_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :messages_read)
end
pipeline :require_privileged_role_users_manage_tags do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_tags)
end
pipeline :require_privileged_role_users_manage_activation_state do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
end
pipeline :require_privileged_role_users_manage_invites do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_invites)
end
pipeline :require_privileged_role_reports_manage_reports do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :reports_manage_reports)
end
pipeline :require_privileged_role_users_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_read)
end
pipeline :require_privileged_role_messages_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :messages_delete)
end
pipeline :require_privileged_role_emoji_manage_emoji do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :emoji_manage_emoji)
end
pipeline :require_privileged_role_instances_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :instances_delete)
end
pipeline :require_privileged_role_moderation_log_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :moderation_log_read)
end
pipeline :require_privileged_role_statistics_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :statistics_read)
end
pipeline :require_privileged_role_announcements_manage_announcements do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :announcements_manage_announcements)
end
pipeline :pleroma_html do
plug(:browser)
plug(:authenticate)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :well_known do
plug(:accepts, ["activity+json", "json", "jrd", "jrd+json", "xml", "xrd+xml", "html"])
end
pipeline :config do
plug(:accepts, ["json", "xml"])
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
end
pipeline :pleroma_api do
plug(:accepts, ["html", "json"])
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :mailbox_preview do
plug(:accepts, ["html"])
plug(:put_secure_browser_headers, %{
"content-security-policy" =>
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' 'unsafe-eval'"
})
end
pipeline :http_signature do
plug(Pleroma.Web.Plugs.HTTPSignaturePlug)
plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug)
+ plug(Pleroma.Web.Plugs.EnsureHostMatchesPlug)
end
pipeline :inbox_guard do
plug(Pleroma.Web.Plugs.InboxGuardPlug)
end
pipeline :static_fe do
plug(Pleroma.Web.Plugs.StaticFEPlug)
end
scope "/api/v1/pleroma", Pleroma.Web.OAuth do
pipe_through(:pleroma_api)
get("/password_reset/:token", PasswordController, :reset, as: :reset_password)
post("/password_reset", PasswordController, :do_reset, as: :reset_password)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:pleroma_api)
get("/emoji", UtilController, :emoji)
get("/captcha", UtilController, :captcha)
get("/healthcheck", UtilController, :healthcheck)
get("/federation_status", InstancesController, :show)
end
scope "/api/v1/pleroma", Pleroma.Web.RemoteInteraction do
pipe_through(:pleroma_api)
post("/remote_interaction", RemoteInteractionController, :remote_interaction)
end
scope "/api/v1/pleroma", Pleroma.Web do
pipe_through(:pleroma_api)
post("/uploader_callback/:upload_path", UploaderController, :callback)
end
# AdminAPI: only admins can perform these actions
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through([:admin_api, :require_admin])
get("/users/:nickname/permission_group", AdminAPIController, :right_get)
get("/users/:nickname/permission_group/:permission_group", AdminAPIController, :right_get)
post("/users/:nickname/permission_group/:permission_group", AdminAPIController, :right_add)
delete(
"/users/:nickname/permission_group/:permission_group",
AdminAPIController,
:right_delete
)
post("/users/permission_group/:permission_group", AdminAPIController, :right_add_multiple)
delete(
"/users/permission_group/:permission_group",
AdminAPIController,
:right_delete_multiple
)
post("/users/follow", UserController, :follow)
post("/users/unfollow", UserController, :unfollow)
post("/users", UserController, :create)
patch("/users/suggest", UserController, :suggest)
patch("/users/unsuggest", UserController, :unsuggest)
get("/relay", RelayController, :index)
post("/relay", RelayController, :follow)
delete("/relay", RelayController, :unfollow)
get("/instance_document/:name", InstanceDocumentController, :show)
patch("/instance_document/:name", InstanceDocumentController, :update)
delete("/instance_document/:name", InstanceDocumentController, :delete)
get("/config", ConfigController, :show)
post("/config", ConfigController, :update)
get("/config/descriptions", ConfigController, :descriptions)
get("/need_reboot", AdminAPIController, :need_reboot)
get("/restart", AdminAPIController, :restart)
get("/oauth_app", OAuthAppController, :index)
post("/oauth_app", OAuthAppController, :create)
patch("/oauth_app/:id", OAuthAppController, :update)
delete("/oauth_app/:id", OAuthAppController, :delete)
get("/media_proxy_caches", MediaProxyCacheController, :index)
post("/media_proxy_caches/delete", MediaProxyCacheController, :delete)
post("/media_proxy_caches/purge", MediaProxyCacheController, :purge)
get("/frontends", FrontendController, :index)
post("/frontends/install", FrontendController, :install)
post("/backups", AdminAPIController, :create_backup)
get("/rules", RuleController, :index)
post("/rules", RuleController, :create)
patch("/rules/:id", RuleController, :update)
delete("/rules/:id", RuleController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_announcements_manage_announcements)
get("/announcements", AnnouncementController, :index)
post("/announcements", AnnouncementController, :create)
get("/announcements/:id", AnnouncementController, :show)
patch("/announcements/:id", AnnouncementController, :change)
delete("/announcements/:id", AnnouncementController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_delete)
delete("/users", UserController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_credentials)
get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset)
get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials)
patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials)
put("/users/disable_mfa", AdminAPIController, :disable_mfa)
patch("/users/force_password_reset", AdminAPIController, :force_password_reset)
patch("/users/confirm_email", AdminAPIController, :confirm_email)
patch("/users/resend_confirmation_email", AdminAPIController, :resend_confirmation_email)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_messages_read)
get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses)
get("/users/:nickname/chats", AdminAPIController, :list_user_chats)
get("/statuses", StatusController, :index)
get("/chats/:id", ChatController, :show)
get("/chats/:id/messages", ChatController, :messages)
get("/instances/:instance/statuses", InstanceController, :list_statuses)
get("/statuses/:id", StatusController, :show)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_tags)
put("/users/tag", AdminAPIController, :tag_users)
delete("/users/tag", AdminAPIController, :untag_users)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_activation_state)
patch("/users/:nickname/toggle_activation", UserController, :toggle_activation)
patch("/users/activate", UserController, :activate)
patch("/users/deactivate", UserController, :deactivate)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_invites)
patch("/users/approve", UserController, :approve)
post("/users/invite_token", InviteController, :create)
get("/users/invites", InviteController, :index)
post("/users/revoke_invite", InviteController, :revoke)
post("/users/email_invite", InviteController, :email)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_reports_manage_reports)
get("/reports", ReportController, :index)
get("/reports/:id", ReportController, :show)
patch("/reports", ReportController, :update)
post("/reports/assign_account", ReportController, :assign_account)
post("/reports/:id/notes", ReportController, :notes_create)
delete("/reports/:report_id/notes/:id", ReportController, :notes_delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_read)
get("/users", UserController, :index)
get("/users/:nickname", UserController, :show)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_messages_delete)
put("/statuses/:id", StatusController, :update)
delete("/statuses/:id", StatusController, :delete)
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_emoji_manage_emoji)
post("/reload_emoji", AdminAPIController, :reload_emoji)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_instances_delete)
delete("/instances/:instance", InstanceController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_moderation_log_read)
get("/moderation_log", AdminAPIController, :list_log)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_statistics_read)
get("/stats", AdminAPIController, :stats)
end
scope "/api/v1/pleroma/emoji", Pleroma.Web.PleromaAPI do
scope "/pack" do
pipe_through(:require_privileged_role_emoji_manage_emoji)
post("/", EmojiPackController, :create)
patch("/", EmojiPackController, :update)
delete("/", EmojiPackController, :delete)
end
scope "/pack" do
pipe_through(:api)
get("/", EmojiPackController, :show)
end
# Modifying packs
scope "/packs" do
pipe_through(:require_privileged_role_emoji_manage_emoji)
get("/import", EmojiPackController, :import_from_filesystem)
get("/remote", EmojiPackController, :remote)
post("/download", EmojiPackController, :download)
post("/download_zip", EmojiPackController, :download_zip)
post("/files", EmojiFileController, :create)
patch("/files", EmojiFileController, :update)
delete("/files", EmojiFileController, :delete)
end
# Pack info / downloading
scope "/packs" do
pipe_through(:api)
get("/", EmojiPackController, :index)
get("/archive", EmojiPackController, :archive)
end
end
scope "/", Pleroma.Web.RemoteInteraction do
pipe_through(:pleroma_html)
post("/main/ostatus", RemoteInteractionController, :remote_subscribe)
get("/main/ostatus", RemoteInteractionController, :show_subscribe_form)
get("/ostatus_subscribe", RemoteInteractionController, :follow)
post("/ostatus_subscribe", RemoteInteractionController, :do_follow)
get("/authorize_interaction", RemoteInteractionController, :authorize_interaction)
end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
post("/change_email", UtilController, :change_email)
post("/change_password", UtilController, :change_password)
post("/delete_account", UtilController, :delete_account)
put("/notification_settings", UtilController, :update_notification_settings)
post("/disable_account", UtilController, :disable_account)
post("/move_account", UtilController, :move_account)
put("/aliases", UtilController, :add_alias)
get("/aliases", UtilController, :list_aliases)
delete("/aliases", UtilController, :delete_alias)
end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
post("/mutes_import", UserImportController, :mutes)
post("/blocks_import", UserImportController, :blocks)
post("/follow_import", UserImportController, :follow)
get("/accounts/mfa", TwoFactorAuthenticationController, :settings)
get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes)
get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup)
post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm)
delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable)
end
scope "/oauth", Pleroma.Web.OAuth do
# Note: use /api/v1/accounts/verify_credentials for userinfo of signed-in user
get("/registration_details", OAuthController, :registration_details)
post("/mfa/verify", MFAController, :verify, as: :mfa_verify)
get("/mfa", MFAController, :show)
scope [] do
pipe_through(:oauth)
get("/authorize", OAuthController, :authorize)
post("/authorize", OAuthController, :create_authorization)
end
scope [] do
pipe_through(:fetch_session)
post("/token", OAuthController, :token_exchange)
post("/revoke", OAuthController, :token_revoke)
post("/mfa/challenge", MFAController, :challenge)
end
scope [] do
pipe_through(:browser)
get("/prepare_request", OAuthController, :prepare_request)
get("/:provider", OAuthController, :request)
get("/:provider/callback", OAuthController, :callback)
post("/register", OAuthController, :register)
end
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:api)
get("/apps", AppController, :index)
get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index)
get("/statuses/:id/reactions", EmojiReactionController, :index)
get(
"/preferred_frontend/available",
FrontendSettingsController,
:available_frontends
)
put(
"/preferred_frontend",
FrontendSettingsController,
:update_preferred_frontend
)
end
scope "/api/v0/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
get("/reports", ReportController, :index)
get("/reports/:id", ReportController, :show)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
scope [] do
pipe_through(:authenticated_api)
post("/chats/by-account-id/:id", ChatController, :create)
get("/chats", ChatController, :index)
get("/chats/:id", ChatController, :show)
get("/chats/:id/messages", ChatController, :messages)
post("/chats/:id/messages", ChatController, :post_chat_message)
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
post("/chats/:id/read", ChatController, :mark_as_read)
post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read)
post("/chats/:id/pin", ChatController, :pin)
post("/chats/:id/unpin", ChatController, :unpin)
get("/conversations/:id/statuses", ConversationController, :statuses)
get("/conversations/:id", ConversationController, :show)
post("/conversations/read", ConversationController, :mark_as_read)
patch("/conversations/:id", ConversationController, :update)
put("/statuses/:id/reactions/:emoji", EmojiReactionController, :create)
delete("/statuses/:id/reactions/:emoji", EmojiReactionController, :delete)
post("/notifications/read", NotificationController, :mark_as_read)
get("/mascot", MascotController, :show)
put("/mascot", MascotController, :update)
post("/scrobble", ScrobbleController, :create)
get("/backups", BackupController, :index)
post("/backups", BackupController, :create)
get("/bookmark_folders", BookmarkFolderController, :index)
post("/bookmark_folders", BookmarkFolderController, :create)
patch("/bookmark_folders/:id", BookmarkFolderController, :update)
delete("/bookmark_folders/:id", BookmarkFolderController, :delete)
get("/outgoing_follow_requests", FollowRequestController, :outgoing)
end
scope [] do
pipe_through(:api)
get("/accounts/:id/favourites", AccountController, :favourites)
get("/statuses/:id/quotes", StatusController, :quotes)
end
scope [] do
pipe_through(:authenticated_api)
post("/accounts/:id/subscribe", AccountController, :subscribe)
post("/accounts/:id/unsubscribe", AccountController, :unsubscribe)
get("/birthdays", AccountController, :birthdays)
end
scope [] do
pipe_through(:authenticated_api)
get("/settings/:app", SettingsController, :show)
patch("/settings/:app", SettingsController, :update)
end
post("/accounts/confirmation_resend", AccountController, :confirmation_resend)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:api)
get("/accounts/:id/scrobbles", ScrobbleController, :index)
end
scope "/api/v1/pleroma", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/accounts/:id/endorsements", AccountController, :endorsements)
end
scope "/api/v2/pleroma", Pleroma.Web.PleromaAPI do
scope [] do
pipe_through(:authenticated_api)
get("/chats", ChatController, :index2)
end
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:authenticated_api)
get("/accounts/verify_credentials", AccountController, :verify_credentials)
patch("/accounts/update_credentials", AccountController, :update_credentials)
get("/accounts/relationships", AccountController, :relationships)
get("/accounts/familiar_followers", AccountController, :familiar_followers)
get("/accounts/:id/lists", AccountController, :lists)
get("/endorsements", AccountController, :own_endorsements)
get("/blocks", AccountController, :blocks)
get("/mutes", AccountController, :mutes)
post("/follows", AccountController, :follow_by_uri)
post("/accounts/:id/follow", AccountController, :follow)
post("/accounts/:id/unfollow", AccountController, :unfollow)
post("/accounts/:id/block", AccountController, :block)
post("/accounts/:id/unblock", AccountController, :unblock)
post("/accounts/:id/mute", AccountController, :mute)
post("/accounts/:id/unmute", AccountController, :unmute)
post("/accounts/:id/note", AccountController, :note)
post("/accounts/:id/pin", AccountController, :endorse)
post("/accounts/:id/unpin", AccountController, :unendorse)
post("/accounts/:id/endorse", AccountController, :endorse)
post("/accounts/:id/unendorse", AccountController, :unendorse)
post("/accounts/:id/remove_from_followers", AccountController, :remove_from_followers)
get("/conversations", ConversationController, :index)
post("/conversations/:id/read", ConversationController, :mark_as_read)
delete("/conversations/:id", ConversationController, :delete)
get("/domain_blocks", DomainBlockController, :index)
post("/domain_blocks", DomainBlockController, :create)
delete("/domain_blocks", DomainBlockController, :delete)
get("/filters", FilterController, :index)
post("/filters", FilterController, :create)
get("/filters/:id", FilterController, :show)
put("/filters/:id", FilterController, :update)
delete("/filters/:id", FilterController, :delete)
get("/follow_requests", FollowRequestController, :index)
post("/follow_requests/:id/authorize", FollowRequestController, :authorize)
post("/follow_requests/:id/reject", FollowRequestController, :reject)
get("/lists", ListController, :index)
get("/lists/:id", ListController, :show)
get("/lists/:id/accounts", ListController, :list_accounts)
delete("/lists/:id", ListController, :delete)
post("/lists", ListController, :create)
put("/lists/:id", ListController, :update)
post("/lists/:id/accounts", ListController, :add_to_list)
delete("/lists/:id/accounts", ListController, :remove_from_list)
get("/markers", MarkerController, :index)
post("/markers", MarkerController, :upsert)
post("/media", MediaController, :create)
get("/media/:id", MediaController, :show)
put("/media/:id", MediaController, :update)
get("/notifications", NotificationController, :index)
get("/notifications/:id", NotificationController, :show)
post("/notifications/:id/dismiss", NotificationController, :dismiss)
post("/notifications/clear", NotificationController, :clear)
delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple)
# Deprecated: was removed in Mastodon v3, use `/notifications/:id/dismiss` instead
post("/notifications/dismiss", NotificationController, :dismiss_via_body)
post("/polls/:id/votes", PollController, :vote)
post("/reports", ReportController, :create)
get("/scheduled_statuses", ScheduledActivityController, :index)
get("/scheduled_statuses/:id", ScheduledActivityController, :show)
put("/scheduled_statuses/:id", ScheduledActivityController, :update)
delete("/scheduled_statuses/:id", ScheduledActivityController, :delete)
# Unlike `GET /api/v1/accounts/:id/favourites`, demands authentication
get("/favourites", StatusController, :favourites)
get("/bookmarks", StatusController, :bookmarks)
post("/statuses", StatusController, :create)
put("/statuses/:id", StatusController, :update)
delete("/statuses/:id", StatusController, :delete)
post("/statuses/:id/reblog", StatusController, :reblog)
post("/statuses/:id/unreblog", StatusController, :unreblog)
post("/statuses/:id/favourite", StatusController, :favourite)
post("/statuses/:id/unfavourite", StatusController, :unfavourite)
post("/statuses/:id/pin", StatusController, :pin)
post("/statuses/:id/unpin", StatusController, :unpin)
post("/statuses/:id/bookmark", StatusController, :bookmark)
post("/statuses/:id/unbookmark", StatusController, :unbookmark)
post("/statuses/:id/mute", StatusController, :mute_conversation)
post("/statuses/:id/unmute", StatusController, :unmute_conversation)
post("/statuses/:id/translate", StatusController, :translate)
get("/statuses/:id/quotes", StatusController, :quotes)
post("/push/subscription", SubscriptionController, :create)
get("/push/subscription", SubscriptionController, :show)
put("/push/subscription", SubscriptionController, :update)
delete("/push/subscription", SubscriptionController, :delete)
get("/suggestions", SuggestionController, :index)
delete("/suggestions/:account_id", SuggestionController, :dismiss)
get("/timelines/home", TimelineController, :home)
get("/timelines/direct", TimelineController, :direct)
get("/timelines/list/:list_id", TimelineController, :list)
get("/announcements", AnnouncementController, :index)
post("/announcements/:id/dismiss", AnnouncementController, :mark_read)
get("/tags/:id", TagController, :show)
post("/tags/:id/follow", TagController, :follow)
post("/tags/:id/unfollow", TagController, :unfollow)
get("/followed_tags", TagController, :show_followed)
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:app_api)
post("/apps", AppController, :create)
get("/apps/verify_credentials", AppController, :verify_credentials)
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/accounts/search", SearchController, :account_search)
get("/search", SearchController, :search)
get("/accounts/lookup", AccountController, :lookup)
get("/accounts/:id/statuses", AccountController, :statuses)
get("/accounts/:id/followers", AccountController, :followers)
get("/accounts/:id/following", AccountController, :following)
get("/accounts/:id/endorsements", AccountController, :endorsements)
get("/accounts/:id", AccountController, :show)
post("/accounts", AccountController, :create)
get("/instance", InstanceController, :show)
get("/instance/peers", InstanceController, :peers)
get("/instance/rules", InstanceController, :rules)
get("/instance/domain_blocks", InstanceController, :domain_blocks)
get("/instance/translation_languages", InstanceController, :translation_languages)
get("/statuses", StatusController, :index)
get("/statuses/:id", StatusController, :show)
get("/statuses/:id/context", StatusController, :context)
get("/statuses/:id/favourited_by", StatusController, :favourited_by)
get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
get("/statuses/:id/history", StatusController, :show_history)
get("/statuses/:id/source", StatusController, :show_source)
get("/custom_emojis", CustomEmojiController, :index)
get("/trends", MastodonAPIController, :empty_array)
get("/timelines/public", TimelineController, :public)
get("/timelines/tag/:tag", TimelineController, :hashtag)
get("/polls/:id", PollController, :show)
get("/directory", DirectoryController, :index)
end
scope "/api/v2", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/search", SearchController, :search2)
post("/media", MediaController, :create2)
get("/suggestions", SuggestionController, :index2)
get("/instance", InstanceController, :show2)
end
scope "/api", Pleroma.Web do
pipe_through(:config)
get("/pleroma/frontend_configurations", PleromaAPI.UtilController, :frontend_configurations)
end
scope "/api", Pleroma.Web do
pipe_through(:api)
get(
"/account/confirm_email/:user_id/:token",
OAuth.TokenController,
:confirm_email,
as: :confirm_email
)
end
scope "/api" do
pipe_through(:base_api)
get("/openapi", OpenApiSpex.Plug.RenderSpec, [])
end
scope "/api", Pleroma.Web, as: :authenticated_pleroma_api do
pipe_through(:authenticated_api)
get("/oauth_tokens", OAuth.TokenController, :oauth_tokens)
delete("/oauth_tokens/:id", OAuth.TokenController, :revoke_token)
end
scope "/", Pleroma.Web do
# Note: html format is supported only if static FE is enabled
# Note: http signature is only considered for json requests (no auth for non-json requests)
pipe_through([:accepts_html_json, :http_signature, :static_fe])
get("/objects/:uuid", OStatus.OStatusController, :object)
get("/activities/:uuid", OStatus.OStatusController, :activity)
get("/notice/:id", OStatus.OStatusController, :notice)
# Mastodon compatibility routes
get("/users/:nickname/statuses/:id", OStatus.OStatusController, :object)
get("/users/:nickname/statuses/:id/activity", OStatus.OStatusController, :activity)
end
scope "/", Pleroma.Web do
# Note: html format is supported only if static FE is enabled
# Note: http signature is only considered for json requests (no auth for non-json requests)
pipe_through([:accepts_html_xml_json, :http_signature, :static_fe])
# Note: returns user _profile_ for json requests, redirects to user _feed_ for non-json ones
get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed)
end
scope "/", Pleroma.Web do
pipe_through([:accepts_html_xml])
get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed)
end
scope "/", Pleroma.Web do
pipe_through(:accepts_html)
get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player)
end
scope "/", Pleroma.Web do
pipe_through(:accepts_xml_rss_atom)
get("/tags/:tag", Feed.TagController, :feed, as: :tag_feed)
end
scope "/", Pleroma.Web do
pipe_through(:browser)
get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
get("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :switch)
post("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :do_switch)
end
pipeline :ap_service_actor do
plug(:accepts, ["activity+json", "json"])
end
# Server to Server (S2S) AP interactions
pipeline :activitypub do
plug(:ap_service_actor)
plug(:http_signature)
end
# Client to Server (C2S) AP interactions
pipeline :activitypub_client do
plug(:ap_service_actor)
plug(Pleroma.Web.Plugs.APClientApiEnabledPlug)
plug(:fetch_session)
plug(:authenticate)
plug(:after_auth)
end
# AP interactions used by both S2S and C2S
pipeline :activitypub_server_or_client do
plug(:ap_service_actor)
plug(:fetch_session)
plug(:authenticate)
plug(Pleroma.Web.Plugs.APClientApiEnabledPlug, allow_server: true)
plug(:after_auth)
plug(:http_signature)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub_client])
get("/api/ap/whoami", ActivityPubController, :whoami)
get("/users/:nickname/inbox", ActivityPubController, :read_inbox)
post("/users/:nickname/outbox", ActivityPubController, :update_outbox)
post("/api/ap/upload_media", ActivityPubController, :upload_media)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub_server_or_client])
get("/users/:nickname/outbox", ActivityPubController, :outbox)
get("/users/:nickname/followers", ActivityPubController, :followers)
get("/users/:nickname/following", ActivityPubController, :following)
get("/users/:nickname/collections/featured", ActivityPubController, :pinned)
get("/objects/:uuid/replies", ActivityPubController, :object_replies)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub, :inbox_guard])
post("/inbox", ActivityPubController, :inbox)
post("/users/:nickname/inbox", ActivityPubController, :inbox)
end
scope "/relay", Pleroma.Web.ActivityPub do
pipe_through(:ap_service_actor)
get("/", ActivityPubController, :relay)
scope [] do
pipe_through(:http_signature)
post("/inbox", ActivityPubController, :inbox)
end
get("/following", ActivityPubController, :relay_following)
get("/followers", ActivityPubController, :relay_followers)
end
scope "/internal/fetch", Pleroma.Web.ActivityPub do
pipe_through(:ap_service_actor)
get("/", ActivityPubController, :internal_fetch)
post("/inbox", ActivityPubController, :inbox)
end
scope "/.well-known", Pleroma.Web do
pipe_through(:well_known)
get("/host-meta", WebFinger.WebFingerController, :host_meta)
get("/webfinger", WebFinger.WebFingerController, :webfinger)
get("/nodeinfo", Nodeinfo.NodeinfoController, :schemas)
end
scope "/nodeinfo", Pleroma.Web do
get("/:version", Nodeinfo.NodeinfoController, :nodeinfo)
end
scope "/", Pleroma.Web do
pipe_through(:api)
get("/manifest.json", ManifestController, :show)
end
scope "/", Pleroma.Web do
pipe_through(:pleroma_html)
post("/auth/password", OAuth.PasswordController, :request)
get("/embed/:id", EmbedController, :show)
end
scope "/proxy/", Pleroma.Web do
get("/preview/:sig/:url", MediaProxy.MediaProxyController, :preview)
get("/preview/:sig/:url/:filename", MediaProxy.MediaProxyController, :preview)
get("/:sig/:url", MediaProxy.MediaProxyController, :remote)
get("/:sig/:url/:filename", MediaProxy.MediaProxyController, :remote)
end
if Pleroma.Config.get(:env) == :dev do
scope "/dev" do
pipe_through([:mailbox_preview])
forward("/mailbox", Plug.Swoosh.MailboxPreview, base_path: "/dev/mailbox")
end
end
scope "/" do
pipe_through([:pleroma_html, :authenticate, :require_admin])
live_dashboard("/pleroma/live_dashboard", additional_pages: [oban: Oban.LiveDashboard])
oban_dashboard("/pleroma/oban")
end
# Test-only routes needed to test action dispatching and plug chain execution
if Pleroma.Config.get(:env) == :test do
@test_actions [
:do_oauth_check,
:fallback_oauth_check,
:skip_oauth_check,
:fallback_oauth_skip_publicity_check,
:skip_oauth_skip_publicity_check,
:missing_oauth_check_definition
]
scope "/test/api", Pleroma.Tests do
pipe_through(:api)
for action <- @test_actions do
get("/#{action}", AuthTestController, action)
end
end
scope "/test/authenticated_api", Pleroma.Tests do
pipe_through(:authenticated_api)
for action <- @test_actions do
get("/#{action}", AuthTestController, action)
end
end
end
scope "/", Pleroma.Web.MongooseIM do
get("/user_exists", MongooseIMController, :user_exists)
get("/check_password", MongooseIMController, :check_password)
end
scope "/", Pleroma.Web.Fallback do
get("/registration/:token", RedirectController, :registration_page)
get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta)
match(:*, "/api/pleroma/*path", LegacyPleromaApiRerouterPlug, [])
get("/api/*path", RedirectController, :api_not_implemented)
get("/phoenix/live_dashboard/*path", RedirectController, :live_dashboard)
get("/*path", RedirectController, :redirector_with_preload)
options("/*path", RedirectController, :empty)
end
# /pleroma/{phoenix,oban}/* need to get filtered out from api routes for frontend configuration
# to not drop admin overrides for /pleroma/admin.
@non_api_routes ["/pleroma/live_dashboard", "/pleroma/oban"]
def get_api_routes do
Phoenix.Router.routes(__MODULE__)
|> Enum.reject(fn r -> r.plug == Pleroma.Web.Fallback.RedirectController end)
|> Enum.reject(fn r -> String.starts_with?(r.path, @non_api_routes) end)
|> Enum.map(fn r ->
r.path
|> String.split("/", trim: true)
|> List.first()
end)
|> Enum.uniq()
end
end
diff --git a/lib/pleroma/workers/signature_retry_worker.ex b/lib/pleroma/workers/signature_retry_worker.ex
index 2c4c097dd..28958faff 100644
--- a/lib/pleroma/workers/signature_retry_worker.ex
+++ b/lib/pleroma/workers/signature_retry_worker.ex
@@ -1,254 +1,271 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.SignatureRetryWorker do
alias Pleroma.Instances
alias Pleroma.Signature
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.Federator
+ alias Pleroma.Web.Plugs.EnsureHostMatchesPlug
alias Pleroma.Web.Plugs.MappedSignatureToIdentityPlug
require Logger
use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity]
@impl true
def perform(%Job{
args: %{
"op" => "incoming_failed_signature_ap_doc",
"method" => method,
"params" => params,
"req_headers" => req_headers,
"request_path" => request_path,
"query_string" => query_string
}
})
when is_binary(method) and is_map(params) and is_list(req_headers) and
is_binary(request_path) and is_binary(query_string) do
case normalize_req_headers(req_headers) do
{:ok, req_headers} ->
conn_data = %Plug.Conn{
assigns: %{valid_signature: true},
method: method,
params: params,
req_headers: req_headers,
request_path: request_path,
query_string: query_string
}
signature_actor_result = signature_actor_id(conn_data)
with actor_id = Utils.get_ap_id(params["actor"]),
{:signature_actor, {:ok, signature_actor_id}} <-
{:signature_actor, signature_actor_result},
{:same_actor, true} <- {:same_actor, signature_actor_id == actor_id},
{:ok, %User{}} <- User.get_or_fetch_by_ap_id(actor_id),
{:ok, _public_key} <- Signature.refetch_public_key(conn_data),
{:signature, true} <- {:signature, validate_signature(conn_data)},
{:same_actor, true} <- {:same_actor, validate_same_actor(conn_data)},
+ {:host_header, true} <- {:host_header, validate_host_header(conn_data)},
{:ok, res} <- Federator.perform(:incoming_ap_doc, params) do
unless Instances.reachable?(params["actor"]) do
domain = URI.parse(params["actor"]).host
Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain}))
end
{:ok, res}
else
e -> process_errors(e, retry_log_context(params, request_path, signature_actor_result))
end
e ->
process_errors(e, retry_log_context(params, request_path, nil))
end
end
def perform(%Job{args: %{"op" => "incoming_failed_signature_ap_doc"} = args}) do
process_errors(
:missing_signature_retry_metadata,
retry_log_context(Map.get(args, "params"), Map.get(args, "request_path"), nil)
)
end
def perform(%Job{args: args}) when is_map(args) do
process_errors(
:missing_signature_retry_metadata,
retry_log_context(Map.get(args, "params"), Map.get(args, "request_path"), nil)
)
end
def perform(%Job{}), do: process_errors(:missing_signature_retry_metadata)
@impl true
def timeout(%_{args: %{"timeout" => timeout}}), do: timeout
def timeout(_job), do: :timer.seconds(5)
defp normalize_req_headers(req_headers) do
req_headers
|> Enum.reduce_while({:ok, []}, fn
{key, value}, {:ok, acc} when is_binary(key) and is_binary(value) ->
{:cont, {:ok, [{key, value} | acc]}}
[key, value], {:ok, acc} when is_binary(key) and is_binary(value) ->
{:cont, {:ok, [{key, value} | acc]}}
_, _ ->
{:halt, {:error, :invalid_signature_retry_metadata}}
end)
|> case do
{:ok, headers} -> {:ok, Enum.reverse(headers)}
error -> error
end
end
+ defp validate_host_header(conn_data) do
+ case EnsureHostMatchesPlug.call(conn_data, []) do
+ %Plug.Conn{assigns: %{valid_signature: true, valid_host_header: true}} ->
+ true
+
+ _ ->
+ false
+ end
+ end
+
defp validate_same_actor(conn_data) do
case MappedSignatureToIdentityPlug.call(conn_data, []) do
%Plug.Conn{assigns: %{valid_signature: true}} ->
true
_ ->
false
end
end
defp validate_signature(conn_data) do
Signature.validate_signature(conn_data)
rescue
_ -> false
catch
_, _ -> false
end
defp signature_actor_id(conn_data) do
Signature.get_actor_id(conn_data)
rescue
_ -> {:error, :invalid_signature}
catch
_, _ -> {:error, :invalid_signature}
end
defp process_errors(errors, context \\ %{})
defp process_errors({:error, {:error, _} = error}, context), do: process_errors(error, context)
defp process_errors(errors, context) do
result =
case errors do
# User fetch failures
{:error, :not_found} = reason ->
{:cancel, reason}
{:error, :forbidden} = reason ->
{:cancel, reason}
# Inactive user
{:error, {:user_active, false} = reason} ->
{:cancel, reason}
# Validator will error and return a changeset error
# e.g., duplicate activities or if the object was deleted
{:error, {:validate, {:error, _changeset} = reason}} ->
{:cancel, reason}
# Duplicate detection during Normalization
{:error, :already_present} ->
{:cancel, :already_present}
# MRFs will return a reject
{:error, {:reject, _} = reason} ->
{:cancel, reason}
# HTTP Sigs
{:signature_actor, {:error, _}} ->
{:cancel, :invalid_signature}
{:signature, false} ->
{:cancel, :invalid_signature}
{:same_actor, false} ->
{:cancel, :actor_signature_mismatch}
+ # Host header from request not for us
+ {:host_header, false} ->
+ {:cancel, :host_header_mismatch}
+
# Origin / URL validation failed somewhere possibly due to spoofing
{:error, :origin_containment_failed} ->
{:cancel, :origin_containment_failed}
# Unclear if this can be reached
{:error, {:side_effects, {:error, :no_object_actor}} = reason} ->
{:cancel, reason}
# Fail closed if the retry cannot reconstruct the original request.
:missing_signature_retry_metadata ->
{:cancel, :missing_signature_retry_metadata}
{:error, :invalid_signature_retry_metadata} ->
{:cancel, :invalid_signature_retry_metadata}
# Catchall
{:error, _} = e ->
e
e ->
{:error, e}
end
log_signature_retry_rejection(result, context)
result
end
defp retry_log_context(params, request_path, signature_actor_result) when is_map(params) do
signature_actor =
case signature_actor_result do
{:ok, actor} when is_binary(actor) -> actor
actor when is_binary(actor) -> actor
_ -> nil
end
%{
activity_id: params["id"],
payload_actor: Utils.get_ap_id(params["actor"]),
request_path: request_path,
signature_actor: signature_actor,
type: params["type"]
}
end
defp retry_log_context(_params, request_path, signature_actor_result) do
signature_actor =
case signature_actor_result do
{:ok, actor} when is_binary(actor) -> actor
actor when is_binary(actor) -> actor
_ -> nil
end
%{
activity_id: nil,
payload_actor: nil,
request_path: request_path,
signature_actor: signature_actor,
type: nil
}
end
defp log_signature_retry_rejection({:cancel, reason}, context)
when reason in [
:actor_signature_mismatch,
+ :host_header_mismatch,
:invalid_signature,
:invalid_signature_retry_metadata,
:missing_signature_retry_metadata,
:origin_containment_failed
] do
Logger.warning(
"Failed-signature inbox retry rejected " <>
"reason=#{inspect(reason)} " <>
"payload_actor=#{inspect(context[:payload_actor])} " <>
"signature_actor=#{inspect(context[:signature_actor])} " <>
"activity_id=#{inspect(context[:activity_id])} " <>
"type=#{inspect(context[:type])} " <>
"request_path=#{inspect(context[:request_path])}"
)
end
defp log_signature_retry_rejection(_result, _context), do: :ok
end
diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
index 3988c3912..68e39b3a0 100644
--- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
+++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs
@@ -1,2919 +1,2963 @@
# 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.ActivityPubControllerTest do
use Pleroma.Web.ConnCase
use Oban.Testing, repo: Pleroma.Repo
alias Pleroma.Activity
alias Pleroma.Delivery
alias Pleroma.Object
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.ObjectView
alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.ActivityPub.UserView
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Endpoint
alias Pleroma.Workers.ReceiverWorker
alias Pleroma.Workers.SignatureRetryWorker
import Pleroma.Factory
require Pleroma.Constants
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
setup do: clear_config([:instance, :federating], true)
defp assign_valid_signature_for_actor(conn, %User{ap_id: actor_id}) do
assign_valid_signature_for_actor(conn, actor_id)
end
defp assign_valid_signature_for_actor(conn, actor) do
actor_id = Utils.get_ap_id(actor)
conn
|> assign(:valid_signature, true)
|> put_req_header("signature", "keyId=\"#{actor_id}#main-key\"")
end
defp expect_signature_retry_from(%User{} = signer) do
signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured")
Tesla.Mock.mock(fn
%{url: url} when url == signer.ap_id ->
%Tesla.Env{
status: 200,
body: Jason.encode!(signer_json),
headers: HttpRequestMock.activitypub_object_headers()
}
env ->
apply(HttpRequestMock, :request, [env])
end)
Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> true end)
end
describe "/relay" do
setup do: clear_config([:instance, :allow_relay])
test "with the relay active, it returns the relay user", %{conn: conn} do
res =
conn
|> get(activity_pub_path(conn, :relay))
|> json_response(200)
assert res["id"] =~ "/relay"
end
test "with the relay disabled, it returns 404", %{conn: conn} do
clear_config([:instance, :allow_relay], false)
conn
|> get(activity_pub_path(conn, :relay))
|> json_response(404)
end
test "on non-federating instance, it returns 404", %{conn: conn} do
clear_config([:instance, :federating], false)
user = insert(:user)
conn
|> assign(:user, user)
|> get(activity_pub_path(conn, :relay))
|> json_response(404)
end
end
describe "/internal/fetch" do
test "it returns the internal fetch user", %{conn: conn} do
res =
conn
|> get(activity_pub_path(conn, :internal_fetch))
|> json_response(200)
assert res["id"] =~ "/fetch"
end
test "on non-federating instance, it returns 404", %{conn: conn} do
clear_config([:instance, :federating], false)
user = insert(:user)
conn
|> assign(:user, user)
|> get(activity_pub_path(conn, :internal_fetch))
|> json_response(404)
end
end
describe "/users/:nickname" do
test "it returns a json representation of the user with accept application/json", %{
conn: conn
} do
user = insert(:user)
conn =
conn
|> put_req_header("accept", "application/json")
|> get("/users/#{user.nickname}")
user = User.get_cached_by_id(user.id)
assert json_response(conn, 200) == UserView.render("user.json", %{user: user})
end
test "it returns a json representation of the user with accept application/activity+json", %{
conn: conn
} do
user = insert(:user)
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}")
user = User.get_cached_by_id(user.id)
assert json_response(conn, 200) == UserView.render("user.json", %{user: user})
end
test "it returns a json representation of the user with accept application/ld+json", %{
conn: conn
} do
user = insert(:user)
conn =
conn
|> put_req_header(
"accept",
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
)
|> get("/users/#{user.nickname}")
user = User.get_cached_by_id(user.id)
assert json_response(conn, 200) == UserView.render("user.json", %{user: user})
end
test "it returns 404 for remote users", %{
conn: conn
} do
user = insert(:user, local: false, nickname: "remoteuser@example.com")
conn =
conn
|> put_req_header("accept", "application/json")
|> get("/users/#{user.nickname}.json")
assert json_response(conn, 404)
end
test "it returns error when user is not found", %{conn: conn} do
response =
conn
|> put_req_header("accept", "application/json")
|> get("/users/jimm")
|> json_response(404)
assert response == "Not found"
end
end
describe "mastodon compatibility routes" do
test "it returns a json representation of the object with accept application/json", %{
conn: conn
} do
{:ok, object} =
%{
"type" => "Note",
"content" => "hey",
"id" => Endpoint.url() <> "/users/raymoo/statuses/999999999",
"actor" => Endpoint.url() <> "/users/raymoo",
"to" => [Pleroma.Constants.as_public()]
}
|> Object.create()
conn =
conn
|> put_req_header("accept", "application/json")
|> get("/users/raymoo/statuses/999999999")
assert json_response(conn, 200) == ObjectView.render("object.json", %{object: object})
end
test "it returns a json representation of the activity with accept application/json", %{
conn: conn
} do
{:ok, object} =
%{
"type" => "Note",
"content" => "hey",
"id" => Endpoint.url() <> "/users/raymoo/statuses/999999999",
"actor" => Endpoint.url() <> "/users/raymoo",
"to" => [Pleroma.Constants.as_public()]
}
|> Object.create()
{:ok, activity, _} =
%{
"id" => object.data["id"] <> "/activity",
"type" => "Create",
"object" => object.data["id"],
"actor" => object.data["actor"],
"to" => object.data["to"]
}
|> ActivityPub.persist(local: true)
conn =
conn
|> put_req_header("accept", "application/json")
|> get("/users/raymoo/statuses/999999999/activity")
assert json_response(conn, 200) == ObjectView.render("object.json", %{object: activity})
end
end
describe "/objects/:uuid" do
test "it doesn't return a local-only object", %{conn: conn} do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "test", visibility: "local"})
assert Pleroma.Web.ActivityPub.Visibility.local_public?(post)
object = Object.normalize(post, fetch: false)
uuid = String.split(object.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/json")
|> get("/objects/#{uuid}")
assert json_response(conn, 404)
end
test "returns local-only objects when authenticated", %{conn: conn} do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "test", visibility: "local"})
assert Pleroma.Web.ActivityPub.Visibility.local_public?(post)
object = Object.normalize(post, fetch: false)
uuid = String.split(object.data["id"], "/") |> List.last()
assert response =
conn
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
assert json_response(response, 200) == ObjectView.render("object.json", %{object: object})
end
test "does not return local-only objects for remote users", %{conn: conn} do
user = insert(:user)
reader = insert(:user, local: false)
{:ok, post} =
CommonAPI.post(user, %{status: "test @#{reader.nickname}", visibility: "local"})
assert Pleroma.Web.ActivityPub.Visibility.local_public?(post)
object = Object.normalize(post, fetch: false)
uuid = String.split(object.data["id"], "/") |> List.last()
assert response =
conn
|> assign(:user, reader)
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
json_response(response, 404)
end
test "it returns a json representation of the object with accept application/json", %{
conn: conn
} do
note = insert(:note)
uuid = String.split(note.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/json")
|> get("/objects/#{uuid}")
assert json_response(conn, 200) == ObjectView.render("object.json", %{object: note})
end
test "it returns a json representation of the object with accept application/activity+json",
%{conn: conn} do
note = insert(:note)
uuid = String.split(note.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
assert json_response(conn, 200) == ObjectView.render("object.json", %{object: note})
end
test "it returns a json representation of the object with accept application/ld+json", %{
conn: conn
} do
note = insert(:note)
uuid = String.split(note.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header(
"accept",
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
)
|> get("/objects/#{uuid}")
assert json_response(conn, 200) == ObjectView.render("object.json", %{object: note})
end
test "does not cache authenticated response", %{conn: conn} do
user = insert(:user)
reader = insert(:user)
{:ok, post} =
CommonAPI.post(user, %{status: "test @#{reader.nickname}", visibility: "local"})
object = Object.normalize(post, fetch: false)
uuid = String.split(object.data["id"], "/") |> List.last()
assert response =
conn
|> assign(:user, reader)
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
json_response(response, 200)
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
|> json_response(404)
end
test "it returns 404 for non-public messages", %{conn: conn} do
note = insert(:direct_note)
uuid = String.split(note.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
assert json_response(conn, 404)
end
test "returns visible non-public messages when authenticated", %{conn: conn} do
note = insert(:direct_note)
uuid = String.split(note.data["id"], "/") |> List.last()
user = User.get_by_ap_id(note.data["actor"])
marisa = insert(:user)
assert conn
|> assign(:user, marisa)
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
|> json_response(404)
assert response =
conn
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
|> json_response(200)
assert response == ObjectView.render("object.json", %{object: note})
end
test "it returns 404 for tombstone objects", %{conn: conn} do
tombstone = insert(:tombstone)
uuid = String.split(tombstone.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
assert json_response(conn, 404)
end
test "it caches a response", %{conn: conn} do
note = insert(:note)
uuid = String.split(note.data["id"], "/") |> List.last()
conn1 =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
assert json_response(conn1, :ok)
assert Enum.any?(conn1.resp_headers, &(&1 == {"x-cache", "MISS from Pleroma"}))
conn2 =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
assert json_response(conn1, :ok) == json_response(conn2, :ok)
assert Enum.any?(conn2.resp_headers, &(&1 == {"x-cache", "HIT from Pleroma"}))
end
test "cached purged after object deletion", %{conn: conn} do
note = insert(:note)
uuid = String.split(note.data["id"], "/") |> List.last()
conn1 =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
assert json_response(conn1, :ok)
assert Enum.any?(conn1.resp_headers, &(&1 == {"x-cache", "MISS from Pleroma"}))
Object.delete(note)
conn2 =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}")
assert "Not found" == json_response(conn2, :not_found)
end
end
describe "/objects/:uuid/replies" do
test "it renders the top-level collection", %{
conn: conn
} do
user = insert(:user)
note = insert(:note_activity)
note = Pleroma.Activity.get_by_id_with_object(note.id)
uuid = String.split(note.object.data["id"], "/") |> List.last()
{:ok, _} =
CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}/replies")
assert match?(
%{
"id" => _,
"type" => "OrderedCollection",
"totalItems" => 1,
"first" => %{
"id" => _,
"type" => "OrderedCollectionPage",
"orderedItems" => [_]
}
},
json_response(conn, 200)
)
end
test "first page id includes `?page=true`", %{conn: conn} do
user = insert(:user)
note = insert(:note_activity)
note = Pleroma.Activity.get_by_id_with_object(note.id)
uuid = String.split(note.object.data["id"], "/") |> List.last()
{:ok, _} =
CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}/replies")
%{"id" => collection_id, "first" => %{"id" => page_id, "partOf" => part_of}} =
json_response(conn, 200)
assert part_of == collection_id
assert String.contains?(page_id, "page=true")
end
test "unknown query params do not crash the endpoint", %{conn: conn} do
user = insert(:user)
note = insert(:note_activity)
note = Pleroma.Activity.get_by_id_with_object(note.id)
uuid = String.split(note.object.data["id"], "/") |> List.last()
{:ok, _} =
CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}/replies?unknown_param=1")
assert %{"type" => "OrderedCollection"} = json_response(conn, 200)
end
test "it renders a collection page", %{
conn: conn
} do
user = insert(:user)
note = insert(:note_activity)
note = Pleroma.Activity.get_by_id_with_object(note.id)
uuid = String.split(note.object.data["id"], "/") |> List.last()
{:ok, r1} =
CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
{:ok, r2} =
CommonAPI.post(user, %{status: "reply2", in_reply_to_status_id: note.id})
{:ok, _} =
CommonAPI.post(user, %{status: "reply3", in_reply_to_status_id: note.id})
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/objects/#{uuid}/replies?page=true&min_id=#{r1.object.id}&limit=1")
expected_uris = [r2.object.data["id"]]
assert match?(
%{
"id" => _,
"type" => "OrderedCollectionPage",
"prev" => _,
"next" => _,
"orderedItems" => ^expected_uris
},
json_response(conn, 200)
)
end
end
describe "/activities/:uuid" do
test "it does not include a top-level replies collection on activities", %{conn: conn} do
clear_config([:activitypub, :note_replies_output_limit], 1)
activity = insert(:note_activity)
activity = Activity.get_by_id_with_object(activity.id)
uuid = String.split(activity.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
res = json_response(conn, 200)
refute Map.has_key?(res, "replies")
assert get_in(res, ["object", "replies", "id"]) == activity.object.data["id"] <> "/replies"
end
test "it doesn't return a local-only activity", %{conn: conn} do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "test", visibility: "local"})
assert Pleroma.Web.ActivityPub.Visibility.local_public?(post)
uuid = String.split(post.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/json")
|> get("/activities/#{uuid}")
assert json_response(conn, 404)
end
test "returns local-only activities when authenticated", %{conn: conn} do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "test", visibility: "local"})
assert Pleroma.Web.ActivityPub.Visibility.local_public?(post)
uuid = String.split(post.data["id"], "/") |> List.last()
assert response =
conn
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
assert json_response(response, 200) == ObjectView.render("object.json", %{object: post})
end
test "it returns a json representation of the activity", %{conn: conn} do
activity = insert(:note_activity)
uuid = String.split(activity.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
assert json_response(conn, 200) == ObjectView.render("object.json", %{object: activity})
end
test "it returns 404 for non-public activities", %{conn: conn} do
activity = insert(:direct_note_activity)
uuid = String.split(activity.data["id"], "/") |> List.last()
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
assert json_response(conn, 404)
end
test "returns visible non-public messages when authenticated", %{conn: conn} do
note = insert(:direct_note_activity)
uuid = String.split(note.data["id"], "/") |> List.last()
user = User.get_by_ap_id(note.data["actor"])
marisa = insert(:user)
assert conn
|> assign(:user, marisa)
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
|> json_response(404)
assert response =
conn
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
|> json_response(200)
assert response == ObjectView.render("object.json", %{object: note})
end
test "it caches a response", %{conn: conn} do
activity = insert(:note_activity)
uuid = String.split(activity.data["id"], "/") |> List.last()
conn1 =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
assert json_response(conn1, :ok)
assert Enum.any?(conn1.resp_headers, &(&1 == {"x-cache", "MISS from Pleroma"}))
conn2 =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
assert json_response(conn1, :ok) == json_response(conn2, :ok)
assert Enum.any?(conn2.resp_headers, &(&1 == {"x-cache", "HIT from Pleroma"}))
end
test "cached purged after activity deletion", %{conn: conn} do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "cofe"})
uuid = String.split(activity.data["id"], "/") |> List.last()
conn1 =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
assert json_response(conn1, :ok)
assert Enum.any?(conn1.resp_headers, &(&1 == {"x-cache", "MISS from Pleroma"}))
Activity.delete_all_by_object_ap_id(activity.object.data["id"])
conn2 =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/activities/#{uuid}")
assert "Not found" == json_response(conn2, :not_found)
end
end
describe "/inbox" do
test "it inserts an incoming activity into the database", %{conn: conn} do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!()
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it inserts an incoming activity into the database" <>
"even if we can't fetch the user but have it in our db",
%{conn: conn} do
user =
insert(:user,
ap_id: "https://mastodon.example.org/users/raymoo",
local: false,
last_refreshed_at: nil
)
data =
File.read!("test/fixtures/mastodon-post-activity.json")
|> Jason.decode!()
|> Map.put("actor", user.ap_id)
|> put_in(["object", "attributedTo"], user.ap_id)
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "does not create a forged post after failed signature retry", %{conn: conn} do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
object_id = "https://two.com/objects/inbox-forged-note"
data = %{
"type" => "Create",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/inbox-forged-create",
"context" => "https://two.com/contexts/inbox-forged-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => object_id,
"actor" => bob.ap_id,
"attributedTo" => bob.ap_id,
"context" => "https://two.com/contexts/inbox-forged-create",
"content" => "forged post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
expect_signature_retry_from(alice)
conn =
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
assert "ok" == json_response(conn, 200)
assert [{:cancel, :actor_signature_mismatch}] =
ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
refute Activity.get_by_ap_id(data["id"])
refute Object.get_by_ap_id(object_id)
end
test "does not create a forged like after failed signature retry", %{conn: conn} do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
note = insert(:note)
data = %{
"type" => "Like",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/inbox-forged-like",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => note.data["id"]
}
expect_signature_retry_from(alice)
conn =
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
assert "ok" == json_response(conn, 200)
assert [{:cancel, :actor_signature_mismatch}] =
ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
refute Activity.get_by_ap_id(data["id"])
end
test "does not delete an object after failed signature retry", %{conn: conn} do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
note = insert(:note)
object_id = note.data["id"]
data = %{
"type" => "Delete",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/inbox-forged-delete",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => object_id
}
expect_signature_retry_from(alice)
conn =
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
assert "ok" == json_response(conn, 200)
assert [{:cancel, :actor_signature_mismatch}] =
ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
refute Activity.get_by_ap_id(data["id"])
assert %Object{data: %{"type" => "Note"}} = Object.get_by_ap_id(object_id)
end
test "does not create a forged post signed by a different actor", %{conn: conn} do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
object_id = "https://two.com/objects/inbox-signed-forged-note"
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Create",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/inbox-signed-forged-create",
"context" => "https://two.com/contexts/inbox-signed-forged-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => object_id,
"actor" => bob.ap_id,
"attributedTo" => bob.ap_id,
"context" => "https://two.com/contexts/inbox-signed-forged-create",
"content" => "forged post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
expect_signature_retry_from(alice)
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT")
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature",
"keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
|> post("/inbox", data)
assert conn.assigns.valid_signature == false
assert "ok" == json_response(conn, 200)
assert [{:cancel, :actor_signature_mismatch}] =
ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
refute Activity.get_by_ap_id(data["id"])
refute Object.get_by_ap_id(object_id)
end
test "does not create a forged like signed by a different actor", %{conn: conn} do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
note = insert(:note)
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Like",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/inbox-signed-forged-like",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => note.data["id"]
}
expect_signature_retry_from(alice)
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT")
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature",
"keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
|> post("/inbox", data)
assert conn.assigns.valid_signature == false
assert "ok" == json_response(conn, 200)
assert [{:cancel, :actor_signature_mismatch}] =
ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker))
refute Activity.get_by_ap_id(data["id"])
end
+ test "does not process post with Host header not for us", %{conn: conn} do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+ object_id = "https://one.com/objects/inbox-forged-note"
+
+ data = %{
+ "type" => "Create",
+ "actor" => alice.ap_id,
+ "id" => "https://one.com/activities/inbox-forged-create",
+ "context" => "https://one.com/contexts/inbox-forged-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => object_id,
+ "actor" => alice.ap_id,
+ "attributedTo" => alice.ap_id,
+ "context" => "https://one.com/contexts/inbox-forged-create",
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ # Plug will complain when replacing raw host header with put_req_header.
+ # The Plug way is updating conn.host, but that isn't the raw header
+ # and that isn't used in the EnsureHostMatchesPlug, because it doesn't include the port.
+ conn =
+ conn
+ |> assign_valid_signature_for_actor(alice)
+ |> delete_req_header("host")
+ |> put_req_header("content-type", "application/activity+json")
+
+ conn = %{conn | req_headers: conn.req_headers ++ [{"host", "invalid.example.com"}]}
+ conn = post(conn, "/inbox", data)
+
+ assert "Host header does not match this instance" == conn.resp_body
+ assert 400 == conn.status
+ assert true == conn.halted
+
+ refute Activity.get_by_ap_id(data["id"])
+ refute Object.get_by_ap_id(object_id)
+ end
+
test "accept follow activity", %{conn: conn} do
clear_config([:instance, :federating], true)
relay = Relay.get_actor()
assert {:ok, %Activity{} = activity} = Relay.follow("https://relay.mastodon.host/actor")
followed_relay = Pleroma.User.get_by_ap_id("https://relay.mastodon.host/actor")
relay = refresh_record(relay)
accept =
File.read!("test/fixtures/relay/accept-follow.json")
|> String.replace("{{ap_id}}", relay.ap_id)
|> String.replace("{{activity_id}}", activity.data["id"])
assert "ok" ==
conn
|> assign_valid_signature_for_actor(followed_relay)
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", accept)
|> json_response(200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Pleroma.FollowingRelationship.following?(
relay,
followed_relay
)
Mix.shell(Mix.Shell.Process)
on_exit(fn ->
Mix.shell(Mix.Shell.IO)
end)
:ok = Mix.Tasks.Pleroma.Relay.run(["list"])
assert_receive {:mix_shell, :info, ["https://relay.mastodon.host/actor"]}
end
test "without valid signature, " <>
"it accepts Create activities and requires enabled federation",
%{conn: conn} do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!()
non_create_data = File.read!("test/fixtures/mastodon-announce.json") |> Jason.decode!()
conn = put_req_header(conn, "content-type", "application/activity+json")
clear_config([:instance, :federating], false)
conn
|> post("/inbox", data)
|> json_response(403)
conn
|> post("/inbox", non_create_data)
|> json_response(403)
clear_config([:instance, :federating], true)
ret_conn = post(conn, "/inbox", data)
assert "ok" == json_response(ret_conn, 200)
conn
|> post("/inbox", non_create_data)
|> json_response(400)
end
# When activity is delivered to the inbox and we cannot immediately verify signature
# we capture all the params and process it later in the Oban job.
# Once we begin processing it through Oban we risk fetching the actor to validate the
# activity which just leads to inserting a new user to process a Delete not relevant to us.
test "Activities of certain types from an unknown actor are discarded", %{conn: conn} do
example_bad_types =
Pleroma.Constants.activity_types() --
Pleroma.Constants.allowed_activity_types_from_strangers()
Enum.each(example_bad_types, fn bad_type ->
params =
%{
"type" => bad_type,
"actor" => "https://unknown.mastodon.instance/users/somebody"
}
|> Jason.encode!()
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", params)
|> json_response(400)
assert all_enqueued() == []
end)
end
test "Unknown activity types are discarded", %{conn: conn} do
unknown_types = ["Poke", "Read", "Dazzle"]
actor =
insert(:user, local: false, ap_id: "https://unknown.mastodon.instance/users/somebody")
Enum.each(unknown_types, fn bad_type ->
params =
%{
"type" => bad_type,
"actor" => actor.ap_id
}
|> Jason.encode!()
conn
|> assign_valid_signature_for_actor(actor)
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", params)
|> json_response(400)
assert all_enqueued() == []
end)
end
test "accepts Add/Remove activities", %{conn: conn} do
object_id = "c61d6733-e256-4fe1-ab13-1e369789423f"
status =
File.read!("test/fixtures/statuses/note.json")
|> String.replace("{{nickname}}", "lain")
|> String.replace("{{object_id}}", object_id)
object_url = "https://example.com/objects/#{object_id}"
user =
File.read!("test/fixtures/users_mock/user.json")
|> String.replace("{{nickname}}", "lain")
actor = "https://example.com/users/lain"
Tesla.Mock.mock(fn
%{
method: :get,
url: ^object_url
} ->
%Tesla.Env{
status: 200,
body: status,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^actor
} ->
%Tesla.Env{
status: 200,
body: user,
headers: [{"content-type", "application/activity+json"}]
}
%{method: :get, url: "https://example.com/users/lain/collections/featured"} ->
%Tesla.Env{
status: 200,
body:
"test/fixtures/users_mock/masto_featured.json"
|> File.read!()
|> String.replace("{{domain}}", "example.com")
|> String.replace("{{nickname}}", "lain"),
headers: [{"content-type", "application/activity+json"}]
}
end)
data = %{
"id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423f",
"actor" => actor,
"object" => object_url,
"target" => "https://example.com/users/lain/collections/featured",
"type" => "Add",
"to" => [Pleroma.Constants.as_public()]
}
assert "ok" ==
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
|> json_response(200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
user = User.get_cached_by_ap_id(data["actor"])
assert user.pinned_objects[data["object"]]
data = %{
"id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423d",
"actor" => actor,
"object" => object_url,
"target" => "https://example.com/users/lain/collections/featured",
"type" => "Remove",
"to" => [Pleroma.Constants.as_public()]
}
assert "ok" ==
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
|> json_response(200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
user = refresh_record(user)
refute user.pinned_objects[data["object"]]
end
test "mastodon pin/unpin", %{conn: conn} do
status_id = "105786274556060421"
status =
File.read!("test/fixtures/statuses/masto-note.json")
|> String.replace("{{nickname}}", "lain")
|> String.replace("{{status_id}}", status_id)
status_url = "https://example.com/users/lain/statuses/#{status_id}"
user =
File.read!("test/fixtures/users_mock/user.json")
|> String.replace("{{nickname}}", "lain")
actor = "https://example.com/users/lain"
Tesla.Mock.mock(fn
%{
method: :get,
url: ^status_url
} ->
%Tesla.Env{
status: 200,
body: status,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^actor
} ->
%Tesla.Env{
status: 200,
body: user,
headers: [{"content-type", "application/activity+json"}]
}
%{method: :get, url: "https://example.com/users/lain/collections/featured"} ->
%Tesla.Env{
status: 200,
body:
"test/fixtures/users_mock/masto_featured.json"
|> File.read!()
|> String.replace("{{domain}}", "example.com")
|> String.replace("{{nickname}}", "lain"),
headers: [{"content-type", "application/activity+json"}]
}
end)
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"actor" => actor,
"object" => status_url,
"target" => "https://example.com/users/lain/collections/featured",
"type" => "Add"
}
assert "ok" ==
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
|> json_response(200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_object_ap_id_with_object(data["object"])
user = User.get_cached_by_ap_id(data["actor"])
assert user.pinned_objects[data["object"]]
data = %{
"actor" => actor,
"object" => status_url,
"target" => "https://example.com/users/lain/collections/featured",
"type" => "Remove"
}
assert "ok" ==
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
|> json_response(200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_object_ap_id_with_object(data["object"])
user = refresh_record(user)
refute user.pinned_objects[data["object"]]
end
end
describe "/users/:nickname/inbox" do
setup do
data =
File.read!("test/fixtures/mastodon-post-activity.json")
|> Jason.decode!()
[data: data]
end
test "it inserts an incoming activity into the database", %{conn: conn, data: data} do
user = insert(:user)
data =
data
|> Map.put("bcc", [user.ap_id])
|> Kernel.put_in(["object", "bcc"], [user.ap_id])
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it accepts messages with to as string instead of array", %{conn: conn, data: data} do
user = insert(:user)
data =
data
|> Map.put("to", user.ap_id)
|> Map.put("cc", [])
|> Kernel.put_in(["object", "to"], user.ap_id)
|> Kernel.put_in(["object", "cc"], [])
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it accepts messages with cc as string instead of array", %{conn: conn, data: data} do
user = insert(:user)
data =
data
|> Map.put("to", [])
|> Map.put("cc", user.ap_id)
|> Kernel.put_in(["object", "to"], [])
|> Kernel.put_in(["object", "cc"], user.ap_id)
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
%Activity{} = activity = Activity.get_by_ap_id(data["id"])
assert user.ap_id in activity.recipients
end
test "it accepts messages with bcc as string instead of array", %{conn: conn, data: data} do
user = insert(:user)
data =
data
|> Map.put("to", [])
|> Map.put("cc", [])
|> Map.put("bcc", user.ap_id)
|> Kernel.put_in(["object", "to"], [])
|> Kernel.put_in(["object", "cc"], [])
|> Kernel.put_in(["object", "bcc"], user.ap_id)
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it accepts announces with to as string instead of array", %{conn: conn} do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "hey"})
announcer = insert(:user, local: false)
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"actor" => announcer.ap_id,
"id" => "#{announcer.ap_id}/statuses/19512778738411822/activity",
"object" => post.data["object"],
"to" => "https://www.w3.org/ns/activitystreams#Public",
"cc" => [user.ap_id],
"type" => "Announce"
}
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
%Activity{} = activity = Activity.get_by_ap_id(data["id"])
assert "https://www.w3.org/ns/activitystreams#Public" in activity.recipients
end
test "it accepts messages from actors that are followed by the user", %{
conn: conn,
data: data
} do
recipient = insert(:user)
actor = insert(:user, %{ap_id: "http://mastodon.example.org/users/actor"})
{:ok, recipient, actor} = User.follow(recipient, actor)
object =
data["object"]
|> Map.put("attributedTo", actor.ap_id)
data =
data
|> Map.put("actor", actor.ap_id)
|> Map.put("object", object)
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{recipient.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it rejects reads from other users", %{conn: conn} do
user = insert(:user)
other_user = insert(:user)
conn =
conn
|> assign(:user, other_user)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/inbox")
assert json_response(conn, 403)
end
test "it returns a note activity in a collection", %{conn: conn} do
note_activity = insert(:direct_note_activity)
note_object = Object.normalize(note_activity, fetch: false)
user = User.get_cached_by_ap_id(hd(note_activity.data["to"]))
conn =
conn
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/inbox?page=true")
assert response(conn, 200) =~ note_object.data["content"]
end
test "it removes all follower collections but actor's", %{conn: conn} do
[actor, recipient] = insert_pair(:user)
to = [
recipient.ap_id,
recipient.follower_address,
"https://www.w3.org/ns/activitystreams#Public"
]
cc = [recipient.follower_address, actor.follower_address]
data = %{
"@context" => ["https://www.w3.org/ns/activitystreams"],
"type" => "Create",
"id" => Utils.generate_activity_id(),
"to" => to,
"cc" => cc,
"actor" => actor.ap_id,
"object" => %{
"type" => "Note",
"to" => to,
"cc" => cc,
"content" => "It's a note",
"attributedTo" => actor.ap_id,
"id" => Utils.generate_object_id()
}
}
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{recipient.nickname}/inbox", data)
|> json_response(200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert activity = Activity.get_by_ap_id(data["id"])
assert activity.id
assert actor.follower_address in activity.recipients
assert actor.follower_address in activity.data["cc"]
refute recipient.follower_address in activity.recipients
refute recipient.follower_address in activity.data["cc"]
refute recipient.follower_address in activity.data["to"]
end
test "it requires authentication", %{conn: conn} do
user = insert(:user)
conn = put_req_header(conn, "accept", "application/activity+json")
ret_conn = get(conn, "/users/#{user.nickname}/inbox")
assert json_response(ret_conn, 403)
ret_conn =
conn
|> assign(:user, user)
|> get("/users/#{user.nickname}/inbox")
assert json_response(ret_conn, 200)
end
test "forwarded report", %{conn: conn} do
admin = insert(:user, is_admin: true)
actor = insert(:user, local: false)
remote_domain = URI.parse(actor.ap_id).host
reported_user = insert(:user)
note = insert(:note_activity, user: reported_user)
data = %{
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://#{remote_domain}/schemas/litepub-0.1.jsonld",
%{
"@language" => "und"
}
],
"actor" => actor.ap_id,
# CC and TO might either not exist at all, or be empty. We should be able to handle either.
# "cc" => [],
"content" => "test",
"context" => "context",
"id" => "http://#{remote_domain}/activities/02be56cf-35e3-46b4-b2c6-47ae08dfee9e",
"nickname" => reported_user.nickname,
"object" => [
reported_user.ap_id,
%{
"actor" => %{
"actor_type" => "Person",
"approval_pending" => false,
"avatar" => "",
"confirmation_pending" => false,
"deactivated" => false,
"display_name" => "test user",
"id" => reported_user.id,
"local" => false,
"nickname" => reported_user.nickname,
"registration_reason" => nil,
"roles" => %{
"admin" => false,
"moderator" => false
},
"tags" => [],
"url" => reported_user.ap_id
},
"content" => "",
"id" => note.data["id"],
"published" => note.data["published"],
"type" => "Note"
}
],
"published" => note.data["published"],
"state" => "open",
"to" => [],
"type" => "Flag"
}
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{reported_user.nickname}/inbox", data)
|> json_response(200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Pleroma.Repo.aggregate(Activity, :count, :id) == 2
ObanHelpers.perform_all()
Swoosh.TestAssertions.assert_email_sent(
to: {admin.name, admin.email},
html_body: ~r/Reported Account:/i
)
end
test "forwarded report from mastodon", %{conn: conn} do
admin = insert(:user, is_admin: true)
actor = insert(:user, local: false)
remote_domain = URI.parse(actor.ap_id).host
remote_actor = "https://#{remote_domain}/actor"
[reported_user, another] = insert_list(2, :user)
note = insert(:note_activity, user: reported_user)
Pleroma.Web.CommonAPI.favorite(note.id, another)
mock_json_body =
"test/fixtures/mastodon/application_actor.json"
|> File.read!()
|> String.replace("{{DOMAIN}}", remote_domain)
Tesla.Mock.mock(fn %{url: ^remote_actor} ->
%Tesla.Env{
status: 200,
body: mock_json_body,
headers: [{"content-type", "application/activity+json"}]
}
end)
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"actor" => remote_actor,
"content" => "test report",
"id" => "https://#{remote_domain}/e3b12fd1-948c-446e-b93b-a5e67edbe1d8",
"object" => [
reported_user.ap_id,
note.data["object"]
],
"type" => "Flag"
}
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{reported_user.nickname}/inbox", data)
|> json_response(200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
flag_activity = "Flag" |> Pleroma.Activity.Queries.by_type() |> Pleroma.Repo.one()
reported_user_ap_id = reported_user.ap_id
[^reported_user_ap_id, flag_data] = flag_activity.data["object"]
Enum.each(~w(actor content id published type), &Map.has_key?(flag_data, &1))
ObanHelpers.perform_all()
Swoosh.TestAssertions.assert_email_sent(
to: {admin.name, admin.email},
html_body: ~r/#{note.data["object"]}/i
)
end
test "it accepts an incoming Block", %{conn: conn, data: data} do
user = insert(:user)
data =
data
|> Map.put("type", "Block")
|> Map.put("to", [user.ap_id])
|> Map.put("cc", [])
|> Map.put("object", user.ap_id)
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it returns an error when receiving an activity sent to a deactivated user", %{
conn: conn,
data: data
} do
user = insert(:user)
{:ok, _} = User.set_activation(user, false)
data =
data
|> Map.put("bcc", [user.ap_id])
|> Kernel.put_in(["object", "bcc"], [user.ap_id])
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "User deactivated" == json_response(conn, 404)
end
test "it returns an error when receiving an activity sent from a deactivated user", %{
conn: conn,
data: data
} do
sender = insert(:user)
user = insert(:user)
{:ok, _} = User.set_activation(sender, false)
data =
data
|> Map.put("bcc", [user.ap_id])
|> Map.put("actor", sender.ap_id)
|> Kernel.put_in(["object", "bcc"], [user.ap_id])
conn =
conn
|> assign_valid_signature_for_actor(data["actor"])
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "Sender deactivated" == json_response(conn, 404)
end
end
describe "GET /users/:nickname/outbox" do
setup do
Mox.stub_with(Pleroma.StaticStubbedConfigMock, Pleroma.Config)
:ok
end
test "it paginates correctly", %{conn: conn} do
user = insert(:user)
conn = assign(conn, :user, user)
outbox_endpoint = user.ap_id <> "/outbox"
_posts =
for i <- 0..25 do
{:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"})
activity
end
result =
conn
|> put_req_header("accept", "application/activity+json")
|> get(outbox_endpoint <> "?page=true")
|> json_response(200)
result_ids = Enum.map(result["orderedItems"], fn x -> x["id"] end)
assert length(result["orderedItems"]) == 20
assert length(result_ids) == 20
assert result["next"]
assert String.starts_with?(result["next"], outbox_endpoint)
result_next =
conn
|> put_req_header("accept", "application/activity+json")
|> get(result["next"])
|> json_response(200)
result_next_ids = Enum.map(result_next["orderedItems"], fn x -> x["id"] end)
assert length(result_next["orderedItems"]) == 6
assert length(result_next_ids) == 6
refute Enum.find(result_next_ids, fn x -> x in result_ids end)
refute Enum.find(result_ids, fn x -> x in result_next_ids end)
assert String.starts_with?(result["id"], outbox_endpoint)
result_next_again =
conn
|> put_req_header("accept", "application/activity+json")
|> get(result_next["id"])
|> json_response(200)
assert result_next == result_next_again
end
test "it returns 200 even if there're no activities", %{conn: conn} do
user = insert(:user)
outbox_endpoint = user.ap_id <> "/outbox"
conn =
conn
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get(outbox_endpoint)
result = json_response(conn, 200)
assert outbox_endpoint == result["id"]
end
test "it returns a local note activity when authenticated as local user", %{conn: conn} do
user = insert(:user)
reader = insert(:user)
{:ok, note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"})
ap_id = note_activity.data["id"]
resp =
conn
|> assign(:user, reader)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/outbox?page=true")
|> json_response(200)
assert %{"orderedItems" => [%{"id" => ^ap_id}]} = resp
end
test "it does not return a local note activity when unauthenticated", %{conn: conn} do
user = insert(:user)
{:ok, _note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"})
resp =
conn
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/outbox?page=true")
|> json_response(200)
assert %{"orderedItems" => []} = resp
end
test "it does not return a local note activity when C2S API is disabled", %{conn: conn} do
clear_config([:activitypub, :client_api_enabled], false)
user = insert(:user)
reader = insert(:user)
{:ok, _note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"})
resp =
conn
|> assign(:user, reader)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/outbox?page=true")
|> json_response(200)
assert %{"orderedItems" => []} = resp
end
test "it returns a note activity in a collection", %{conn: conn} do
note_activity = insert(:note_activity)
note_object = Object.normalize(note_activity, fetch: false)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
conn =
conn
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/outbox?page=true")
assert response(conn, 200) =~ note_object.data["content"]
end
test "it returns an announce activity in a collection", %{conn: conn} do
announce_activity = insert(:announce_activity)
user = User.get_cached_by_ap_id(announce_activity.data["actor"])
conn =
conn
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get("/users/#{user.nickname}/outbox?page=true")
assert response(conn, 200) =~ announce_activity.data["object"]
end
test "It returns poll Answers when authenticated", %{conn: conn} do
poller = insert(:user)
voter = insert(:user)
{:ok, activity} =
CommonAPI.post(poller, %{
status: "suya...",
poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10}
})
assert question = Object.normalize(activity, fetch: false)
{:ok, [activity], _object} = CommonAPI.vote(question, voter, [1])
assert outbox_get =
conn
|> assign(:user, voter)
|> put_req_header("accept", "application/activity+json")
|> get(voter.ap_id <> "/outbox?page=true")
|> json_response(200)
assert [answer_outbox] = outbox_get["orderedItems"]
assert answer_outbox["id"] == activity.data["id"]
end
test "it works with authorized fetch forced when authenticated" do
clear_config([:activitypub, :authorized_fetch_mode], true)
user = insert(:user)
outbox_endpoint = user.ap_id <> "/outbox"
conn =
build_conn()
|> assign(:user, user)
|> put_req_header("accept", "application/activity+json")
|> get(outbox_endpoint)
assert json_response(conn, 200)
end
test "it fails with authorized fetch forced when unauthenticated", %{conn: conn} do
clear_config([:activitypub, :authorized_fetch_mode], true)
user = insert(:user)
outbox_endpoint = user.ap_id <> "/outbox"
conn =
conn
|> put_req_header("accept", "application/activity+json")
|> get(outbox_endpoint)
assert response(conn, 401)
end
end
describe "POST /users/:nickname/outbox (C2S)" do
setup do: clear_config([:instance, :limit])
setup do
[
activity: %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Create",
"object" => %{
"type" => "Note",
"content" => "AP C2S test",
"to" => "https://www.w3.org/ns/activitystreams#Public",
"cc" => []
}
}
]
end
test "it rejects posts from other users / unauthenticated users", %{
conn: conn,
activity: activity
} do
user = insert(:user)
other_user = insert(:user)
conn = put_req_header(conn, "content-type", "application/activity+json")
conn
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(403)
conn
|> assign(:user, other_user)
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(403)
end
test "it inserts an incoming create activity into the database", %{
conn: conn,
activity: activity
} do
user = insert(:user)
result =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(201)
assert Activity.get_by_ap_id(result["id"])
assert result["object"]
assert %Object{data: object} = Object.normalize(result["object"], fetch: false)
assert object["content"] == activity["object"]["content"]
end
test "it inserts an incoming reply create activity into the database", %{conn: conn} do
user = insert(:user)
replying_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "cofe"})
data = %{
type: "Create",
object: %{
to: [Pleroma.Constants.as_public(), user.ap_id],
cc: [replying_user.follower_address],
inReplyTo: activity.object.data["id"],
content: "green tea",
type: "Note"
}
}
result =
conn
|> assign(:user, replying_user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{replying_user.nickname}/outbox", data)
|> json_response(201)
updated_object = Object.normalize(activity.object.data["id"], fetch: false)
assert Activity.get_by_ap_id(result["id"])
assert result["object"]
assert %Object{data: object} = Object.normalize(result["object"], fetch: false)
assert object["content"] == data.object.content
assert Pleroma.Web.ActivityPub.Visibility.public?(object)
assert object["inReplyTo"] == activity.object.data["id"]
assert updated_object.data["repliesCount"] == 1
end
test "it rejects anything beyond 'Note' creations", %{conn: conn, activity: activity} do
user = insert(:user)
activity =
activity
|> put_in(["object", "type"], "Benis")
_result =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(400)
end
test "it inserts an incoming sensitive activity into the database", %{
conn: conn,
activity: activity
} do
user = insert(:user)
conn = assign(conn, :user, user)
object = Map.put(activity["object"], "sensitive", true)
activity = Map.put(activity, "object", object)
response =
conn
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(201)
assert Activity.get_by_ap_id(response["id"])
assert response["object"]
assert %Object{data: response_object} = Object.normalize(response["object"], fetch: false)
assert response_object["sensitive"] == true
assert response_object["content"] == activity["object"]["content"]
representation =
conn
|> put_req_header("accept", "application/activity+json")
|> get(response["id"])
|> json_response(200)
assert representation["object"]["sensitive"] == true
end
test "it rejects an incoming activity with bogus type", %{conn: conn, activity: activity} do
user = insert(:user)
activity = Map.put(activity, "type", "BadType")
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", activity)
assert json_response(conn, 400)
end
test "it erects a tombstone when receiving a delete activity", %{conn: conn} do
note_activity = insert(:note_activity)
note_object = Object.normalize(note_activity, fetch: false)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
data = %{
"type" => "Delete",
"object" => %{
"id" => note_object.data["id"]
}
}
result =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", data)
|> json_response(201)
assert Activity.get_by_ap_id(result["id"])
assert object = Object.get_by_ap_id(note_object.data["id"])
assert object.data["type"] == "Tombstone"
end
test "it rejects delete activity of object from other actor", %{conn: conn} do
note_activity = insert(:note_activity)
note_object = Object.normalize(note_activity, fetch: false)
user = insert(:user)
data = %{
type: "Delete",
object: %{
id: note_object.data["id"]
}
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", data)
assert json_response(conn, 403)
end
test "it rejects update activity of object from other actor", %{conn: conn} do
note_activity = insert(:note_activity)
note_object = Object.normalize(note_activity, fetch: false)
user = insert(:user)
data = %{
type: "Update",
object: %{
id: note_object.data["id"]
}
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", data)
assert json_response(conn, 400)
assert note_object == Object.normalize(note_activity, fetch: false)
end
test "it rejects Add to other user's collection", %{conn: conn} do
user = insert(:user)
target_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Post"})
object = Object.normalize(activity, fetch: false)
object_id = object.data["id"]
data = %{
type: "Add",
target:
"#{Pleroma.Web.Endpoint.url()}/users/#{target_user.nickname}/collections/featured",
object: object_id
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", data)
assert json_response(conn, 400)
end
test "it rejects Remove to other user's collection", %{conn: conn} do
user = insert(:user)
target_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "Post"})
object = Object.normalize(activity, fetch: false)
object_id = object.data["id"]
data = %{
type: "Remove",
target:
"#{Pleroma.Web.Endpoint.url()}/users/#{target_user.nickname}/collections/featured",
object: object_id
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", data)
assert json_response(conn, 400)
end
test "it rejects updating Actor's profile", %{conn: conn} do
user = insert(:user, local: true)
user_object = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user})
user_object_new = Map.put(user_object, "name", "lain")
data = %{
type: "Update",
object: user_object_new
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/json")
|> post("/users/#{user.nickname}/outbox", data)
updated_user_object = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user})
assert updated_user_object == user_object
assert json_response(conn, 400)
end
# Actor publicKey tests are redundant with above test,
# left here for the case that Updating Actors is ever supported
test "it rejects updating Actor's publicKey", %{conn: conn} do
user = insert(:user, local: true)
{:ok, pem} = Pleroma.Keys.generate_rsa_pem()
{:ok, _, public_key} = Pleroma.Keys.keys_from_pem(pem)
# Taken from UserView
public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)
public_key = :public_key.pem_encode([public_key])
user_object = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user})
user_object_public_key = Map.fetch!(user_object, "publicKey")
user_object_public_key = Map.put(user_object_public_key, "publicKeyPem", public_key)
user_object_new = Map.put(user_object, "publicKey", user_object_public_key)
refute user_object == user_object_new
data = %{
type: "Update",
object: user_object_new
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/json")
|> post("/users/#{user.nickname}/outbox", data)
new_user_object = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user})
assert user_object == new_user_object
assert json_response(conn, 400)
end
test "it rejects updating Actor's publicKey of another user", %{conn: conn} do
user = insert(:user)
target_user = insert(:user, local: true)
{:ok, pem} = Pleroma.Keys.generate_rsa_pem()
{:ok, _, public_key} = Pleroma.Keys.keys_from_pem(pem)
# Taken from UserView
public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)
public_key = :public_key.pem_encode([public_key])
target_user_object =
Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: target_user})
target_user_object_public_key = Map.fetch!(target_user_object, "publicKey")
target_user_object_public_key =
Map.put(target_user_object_public_key, "publicKeyPem", public_key)
target_user_object_new =
Map.put(target_user_object, "publicKey", target_user_object_public_key)
refute target_user_object == target_user_object_new
data = %{
type: "Update",
object: target_user_object_new
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/json")
|> post("/users/#{target_user.nickname}/outbox", data)
new_target_user_object =
Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: target_user})
assert target_user_object == new_target_user_object
assert json_response(conn, 403)
end
test "it rejects creating Actors of type Application", %{conn: conn} do
user = insert(:user, local: true)
data = %{
type: "Create",
object: %{
type: "Application"
}
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/json")
|> post("/users/#{user.nickname}/outbox", data)
assert json_response(conn, 400)
end
test "it rejects creating Actors of type Person", %{conn: conn} do
user = insert(:user, local: true)
data = %{
type: "Create",
object: %{
type: "Person"
}
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/json")
|> post("/users/#{user.nickname}/outbox", data)
assert json_response(conn, 400)
end
test "it rejects creating Actors of type Service", %{conn: conn} do
user = insert(:user, local: true)
data = %{
type: "Create",
object: %{
type: "Service"
}
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/json")
|> post("/users/#{user.nickname}/outbox", data)
assert json_response(conn, 400)
end
test "it rejects like activity to object invisible to actor", %{conn: conn} do
user = insert(:user)
stranger = insert(:user, local: true)
{:ok, post} = CommonAPI.post(user, %{status: "cofe", visibility: "private"})
assert Pleroma.Web.ActivityPub.Visibility.private?(post)
refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(post, stranger)
post_object = Object.normalize(post, fetch: false)
data = %{
type: "Like",
object: %{
id: post_object.data["id"]
}
}
conn =
conn
|> assign(:user, stranger)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{stranger.nickname}/outbox", data)
assert json_response(conn, 403)
end
test "it rejects announce activity to object invisible to actor", %{conn: conn} do
user = insert(:user)
stranger = insert(:user, local: true)
{:ok, post} = CommonAPI.post(user, %{status: "cofe", visibility: "private"})
assert Pleroma.Web.ActivityPub.Visibility.private?(post)
refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(post, stranger)
post_object = Object.normalize(post, fetch: false)
data = %{
type: "Announce",
object: %{
id: post_object.data["id"]
}
}
conn =
conn
|> assign(:user, stranger)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{stranger.nickname}/outbox", data)
assert json_response(conn, 403)
end
test "it rejects emojireact activity to object invisible to actor", %{conn: conn} do
user = insert(:user)
stranger = insert(:user, local: true)
{:ok, post} = CommonAPI.post(user, %{status: "cofe", visibility: "private"})
assert Pleroma.Web.ActivityPub.Visibility.private?(post)
refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(post, stranger)
post_object = Object.normalize(post, fetch: false)
data = %{
type: "EmojiReact",
object: %{
id: post_object.data["id"]
},
content: "😀"
}
conn =
conn
|> assign(:user, stranger)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{stranger.nickname}/outbox", data)
assert json_response(conn, 403)
end
test "it increases like count when receiving a like action", %{conn: conn} do
note_activity = insert(:note_activity)
note_object = Object.normalize(note_activity, fetch: false)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
data = %{
type: "Like",
object: %{
id: note_object.data["id"]
}
}
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", data)
result = json_response(conn, 201)
assert Activity.get_by_ap_id(result["id"])
assert object = Object.get_by_ap_id(note_object.data["id"])
assert object.data["like_count"] == 1
end
test "it doesn't spreads faulty attributedTo or actor fields", %{
conn: conn,
activity: activity
} do
reimu = insert(:user, nickname: "reimu")
cirno = insert(:user, nickname: "cirno")
assert reimu.ap_id
assert cirno.ap_id
activity =
activity
|> put_in(["object", "actor"], reimu.ap_id)
|> put_in(["object", "attributedTo"], reimu.ap_id)
|> put_in(["actor"], reimu.ap_id)
|> put_in(["attributedTo"], reimu.ap_id)
_reimu_outbox =
conn
|> assign(:user, cirno)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{reimu.nickname}/outbox", activity)
|> json_response(403)
cirno_outbox =
conn
|> assign(:user, cirno)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{cirno.nickname}/outbox", activity)
|> json_response(201)
assert cirno_outbox["attributedTo"] == nil
assert cirno_outbox["actor"] == cirno.ap_id
assert cirno_object = Object.normalize(cirno_outbox["object"], fetch: false)
assert cirno_object.data["actor"] == cirno.ap_id
assert cirno_object.data["attributedTo"] == cirno.ap_id
end
test "Character limitation", %{conn: conn, activity: activity} do
clear_config([:instance, :limit], 5)
user = insert(:user)
result =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(400)
assert result == "Character limit (5 characters) exceeded, contains 11 characters"
end
end
describe "/relay/followers" do
test "it returns relay followers", %{conn: conn} do
relay_actor = Relay.get_actor()
user = insert(:user)
User.follow(user, relay_actor)
result =
conn
|> get("/relay/followers")
|> json_response(200)
assert result["first"]["orderedItems"] == [user.ap_id]
end
test "on non-federating instance, it returns 404", %{conn: conn} do
clear_config([:instance, :federating], false)
user = insert(:user)
conn
|> assign(:user, user)
|> get("/relay/followers")
|> json_response(404)
end
end
describe "/relay/following" do
test "it returns relay following", %{conn: conn} do
result =
conn
|> get("/relay/following")
|> json_response(200)
assert result["first"]["orderedItems"] == []
end
test "on non-federating instance, it returns 404", %{conn: conn} do
clear_config([:instance, :federating], false)
user = insert(:user)
conn
|> assign(:user, user)
|> get("/relay/following")
|> json_response(404)
end
end
describe "/users/:nickname/followers" do
test "it returns the followers in a collection", %{conn: conn} do
user = insert(:user)
user_two = insert(:user)
User.follow(user, user_two)
result =
conn
|> assign(:user, user_two)
|> get("/users/#{user_two.nickname}/followers")
|> json_response(200)
assert result["first"]["orderedItems"] == [user.ap_id]
end
test "it returns a uri if the user has 'hide_followers' set", %{conn: conn} do
user = insert(:user)
user_two = insert(:user, hide_followers: true)
User.follow(user, user_two)
result =
conn
|> assign(:user, user)
|> get("/users/#{user_two.nickname}/followers")
|> json_response(200)
assert is_binary(result["first"])
end
test "it returns a 403 error on pages, if the user has 'hide_followers' set and the request is from another user",
%{conn: conn} do
user = insert(:user)
other_user = insert(:user, hide_followers: true)
result =
conn
|> assign(:user, user)
|> get("/users/#{other_user.nickname}/followers?page=1")
assert result.status == 403
assert result.resp_body == ""
end
test "it renders the page, if the user has 'hide_followers' set and the request is authenticated with the same user",
%{conn: conn} do
user = insert(:user, hide_followers: true)
other_user = insert(:user)
{:ok, user, _other_user, _activity} = CommonAPI.follow(user, other_user)
result =
conn
|> assign(:user, user)
|> get("/users/#{user.nickname}/followers?page=1")
|> json_response(200)
assert result["totalItems"] == 1
assert result["orderedItems"] == [other_user.ap_id]
end
test "it works for more than 10 users", %{conn: conn} do
user = insert(:user)
Enum.each(1..15, fn _ ->
other_user = insert(:user)
User.follow(other_user, user)
end)
result =
conn
|> assign(:user, user)
|> get("/users/#{user.nickname}/followers")
|> json_response(200)
assert length(result["first"]["orderedItems"]) == 10
assert result["first"]["totalItems"] == 15
assert result["totalItems"] == 15
result =
conn
|> assign(:user, user)
|> get("/users/#{user.nickname}/followers?page=2")
|> json_response(200)
assert length(result["orderedItems"]) == 5
assert result["totalItems"] == 15
end
test "does not require authentication", %{conn: conn} do
user = insert(:user)
conn
|> get("/users/#{user.nickname}/followers")
|> json_response(200)
end
end
describe "/users/:nickname/following" do
test "it returns the following in a collection", %{conn: conn} do
user = insert(:user)
user_two = insert(:user)
User.follow(user, user_two)
result =
conn
|> assign(:user, user)
|> get("/users/#{user.nickname}/following")
|> json_response(200)
assert result["first"]["orderedItems"] == [user_two.ap_id]
end
test "it returns a uri if the user has 'hide_follows' set", %{conn: conn} do
user = insert(:user)
user_two = insert(:user, hide_follows: true)
User.follow(user, user_two)
result =
conn
|> assign(:user, user)
|> get("/users/#{user_two.nickname}/following")
|> json_response(200)
assert is_binary(result["first"])
end
test "it returns a 403 error on pages, if the user has 'hide_follows' set and the request is from another user",
%{conn: conn} do
user = insert(:user)
user_two = insert(:user, hide_follows: true)
result =
conn
|> assign(:user, user)
|> get("/users/#{user_two.nickname}/following?page=1")
assert result.status == 403
assert result.resp_body == ""
end
test "it renders the page, if the user has 'hide_follows' set and the request is authenticated with the same user",
%{conn: conn} do
user = insert(:user, hide_follows: true)
other_user = insert(:user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
result =
conn
|> assign(:user, user)
|> get("/users/#{user.nickname}/following?page=1")
|> json_response(200)
assert result["totalItems"] == 1
assert result["orderedItems"] == [other_user.ap_id]
end
test "it works for more than 10 users", %{conn: conn} do
user = insert(:user)
Enum.each(1..15, fn _ ->
user = User.get_cached_by_id(user.id)
other_user = insert(:user)
User.follow(user, other_user)
end)
result =
conn
|> assign(:user, user)
|> get("/users/#{user.nickname}/following")
|> json_response(200)
assert length(result["first"]["orderedItems"]) == 10
assert result["first"]["totalItems"] == 15
assert result["totalItems"] == 15
result =
conn
|> assign(:user, user)
|> get("/users/#{user.nickname}/following?page=2")
|> json_response(200)
assert length(result["orderedItems"]) == 5
assert result["totalItems"] == 15
end
test "does not require authentication", %{conn: conn} do
user = insert(:user)
conn
|> get("/users/#{user.nickname}/following")
|> json_response(200)
end
end
describe "delivery tracking" do
test "it tracks a signed object fetch", %{conn: conn} do
user = insert(:user, local: false)
activity = insert(:note_activity)
object = Object.normalize(activity, fetch: false)
object_path = String.trim_leading(object.data["id"], Pleroma.Web.Endpoint.url())
conn
|> put_req_header("accept", "application/activity+json")
|> assign(:user, user)
|> get(object_path)
|> json_response(200)
assert Delivery.get(object.id, user.id)
end
test "it tracks a signed activity fetch", %{conn: conn} do
user = insert(:user, local: false)
activity = insert(:note_activity)
object = Object.normalize(activity, fetch: false)
activity_path = String.trim_leading(activity.data["id"], Pleroma.Web.Endpoint.url())
conn
|> put_req_header("accept", "application/activity+json")
|> assign(:user, user)
|> get(activity_path)
|> json_response(200)
assert Delivery.get(object.id, user.id)
end
test "it tracks a signed object fetch when the json is cached", %{conn: conn} do
user = insert(:user, local: false)
other_user = insert(:user, local: false)
activity = insert(:note_activity)
object = Object.normalize(activity, fetch: false)
object_path = String.trim_leading(object.data["id"], Pleroma.Web.Endpoint.url())
conn
|> put_req_header("accept", "application/activity+json")
|> assign(:user, user)
|> get(object_path)
|> json_response(200)
build_conn()
|> put_req_header("accept", "application/activity+json")
|> assign(:user, other_user)
|> get(object_path)
|> json_response(200)
assert Delivery.get(object.id, user.id)
assert Delivery.get(object.id, other_user.id)
end
test "it tracks a signed activity fetch when the json is cached", %{conn: conn} do
user = insert(:user, local: false)
other_user = insert(:user, local: false)
activity = insert(:note_activity)
object = Object.normalize(activity, fetch: false)
activity_path = String.trim_leading(activity.data["id"], Pleroma.Web.Endpoint.url())
conn
|> put_req_header("accept", "application/activity+json")
|> assign(:user, user)
|> get(activity_path)
|> json_response(200)
build_conn()
|> put_req_header("accept", "application/activity+json")
|> assign(:user, other_user)
|> get(activity_path)
|> json_response(200)
assert Delivery.get(object.id, user.id)
assert Delivery.get(object.id, other_user.id)
end
end
describe "Additional ActivityPub C2S endpoints" do
test "GET /api/ap/whoami", %{conn: conn} do
user = insert(:user)
conn =
conn
|> assign(:user, user)
|> get("/api/ap/whoami")
user = User.get_cached_by_id(user.id)
assert UserView.render("user.json", %{user: user}) == json_response(conn, 200)
conn
|> get("/api/ap/whoami")
|> json_response(403)
end
setup do: clear_config([:media_proxy])
setup do: clear_config([Pleroma.Upload])
# majic's libmagic port is unavailable on local Darwin runs; Linux CI still runs this test.
@tag :skip_darwin
test "POST /api/ap/upload_media", %{conn: conn} do
user = insert(:user)
desc = "Description of the image"
image = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
object =
conn
|> assign(:user, user)
|> post("/api/ap/upload_media", %{"file" => image, "description" => desc})
|> json_response(:created)
assert object["name"] == desc
assert object["type"] == "Document"
assert object["actor"] == user.ap_id
assert [%{"href" => object_href, "mediaType" => object_mediatype}] = object["url"]
assert is_binary(object_href)
assert object_mediatype == "image/jpeg"
assert String.ends_with?(object_href, ".jpg")
activity_request = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Create",
"object" => %{
"type" => "Note",
"content" => "AP C2S test, attachment",
"attachment" => [object],
"to" => "https://www.w3.org/ns/activitystreams#Public",
"cc" => []
}
}
activity_response =
conn
|> assign(:user, user)
|> post("/users/#{user.nickname}/outbox", activity_request)
|> json_response(:created)
assert activity_response["id"]
assert activity_response["object"]
assert activity_response["actor"] == user.ap_id
assert %Object{data: %{"attachment" => [attachment]}} =
Object.normalize(activity_response["object"], fetch: false)
assert attachment["type"] == "Document"
assert attachment["name"] == desc
assert [
%{
"href" => ^object_href,
"type" => "Link",
"mediaType" => ^object_mediatype
}
] = attachment["url"]
# Fails if unauthenticated
conn
|> post("/api/ap/upload_media", %{"file" => image, "description" => desc})
|> json_response(403)
end
test "they don't work when C2S API is disabled", %{conn: conn} do
clear_config([:activitypub, :client_api_enabled], false)
user = insert(:user)
assert conn
|> assign(:user, user)
|> get("/api/ap/whoami")
|> response(403)
desc = "Description of the image"
image = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
assert conn
|> assign(:user, user)
|> post("/api/ap/upload_media", %{"file" => image, "description" => desc})
|> response(403)
end
end
test "pinned collection", %{conn: conn} do
clear_config([:instance, :max_pinned_statuses], 2)
user = insert(:user)
objects = insert_list(2, :note, user: user)
Enum.reduce(objects, user, fn %{data: %{"id" => object_id}}, user ->
{:ok, updated} = User.add_pinned_object_id(user, object_id)
updated
end)
%{nickname: nickname, featured_address: featured_address, pinned_objects: pinned_objects} =
refresh_record(user)
%{"id" => ^featured_address, "orderedItems" => items, "totalItems" => 2} =
conn
|> get("/users/#{nickname}/collections/featured")
|> json_response(200)
object_ids = Enum.map(items, & &1["id"])
assert Enum.all?(pinned_objects, fn {obj_id, _} ->
obj_id in object_ids
end)
end
end
diff --git a/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs b/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs
new file mode 100644
index 000000000..8ace74dfb
--- /dev/null
+++ b/test/pleroma/web/plugs/ensure_host_matches_plug_test.exs
@@ -0,0 +1,121 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlugTest do
+ use Pleroma.Web.ConnCase
+
+ alias Pleroma.Web.Endpoint
+ alias Pleroma.Web.Plugs.EnsureHostMatchesPlug
+
+ import Plug.Conn
+ import Tesla.Mock
+
+ setup do
+ mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
+ :ok
+ end
+
+ defp set_host(conn, host), do: %{conn | req_headers: conn.req_headers ++ [{"host", host}]}
+
+ describe "EnsureHostMatchesPlug" do
+ setup do
+ conn = build_conn(:post, "/cofe") |> assign(:valid_signature, true)
+ [conn: conn]
+ end
+
+ test "gracefully handles no Host header", %{conn: conn} do
+ conn = EnsureHostMatchesPlug.call(conn, %{})
+
+ assert conn.status == 400
+ assert conn.halted == true
+ assert conn.resp_body == "Host header not provided"
+ end
+
+ test "gracefully handles empty Host header", %{conn: conn} do
+ conn =
+ conn
+ |> set_host("")
+ |> EnsureHostMatchesPlug.call(%{})
+
+ assert conn.status == 400
+ assert conn.halted == true
+ assert conn.resp_body == "Host header not provided"
+ end
+
+ test "it rejects Host header not matching Endpoint URL", %{conn: conn} do
+ conn =
+ conn
+ |> set_host("invalid.example.com")
+ |> EnsureHostMatchesPlug.call(%{})
+
+ assert conn.status == 400
+ assert conn.halted == true
+ assert conn.resp_body == "Host header does not match this instance"
+ end
+
+ test "it rejects Host header not matching Endpoint with port", %{conn: conn} do
+ endpoint = URI.parse(Endpoint.url())
+
+ conn =
+ conn
+ |> set_host("invalid.example.com:#{endpoint.port}")
+ |> EnsureHostMatchesPlug.call(%{})
+
+ assert conn.status == 400
+ assert conn.halted == true
+ assert conn.resp_body == "Host header does not match this instance"
+ end
+
+ test "it rejects Host header not matching Endpoint port", %{conn: conn} do
+ endpoint = URI.parse(Endpoint.url())
+
+ conn =
+ conn
+ |> set_host("#{endpoint.host}:25")
+ |> EnsureHostMatchesPlug.call(%{})
+
+ assert conn.status == 400
+ assert conn.halted == true
+ assert conn.resp_body == "Host header does not match this instance"
+ end
+
+ test "it rejects multiple Host headers", %{conn: conn} do
+ conn =
+ conn
+ |> set_host("host1.example.com")
+ |> set_host("host2.example.com")
+ |> EnsureHostMatchesPlug.call(%{})
+
+ assert conn.status == 400
+ assert conn.halted == true
+ assert conn.resp_body == "More than one Host header provided"
+ end
+
+ test "it works for Host header without port", %{conn: conn} do
+ endpoint = URI.parse(Endpoint.url())
+
+ conn =
+ conn
+ |> set_host("#{endpoint.host}")
+ |> EnsureHostMatchesPlug.call(%{})
+
+ assert conn.halted == false
+ assert Map.get(conn.assigns, :valid_host_header, nil)
+ end
+
+ test "it works for Host header with port same as Endpoint", %{
+ conn: conn
+ } do
+ endpoint = URI.parse(Endpoint.url())
+
+ conn =
+ conn
+ |> set_host("#{endpoint.host}:#{endpoint.port}")
+ |> EnsureHostMatchesPlug.call(%{})
+
+ assert conn.halted == false
+ assert Map.get(conn.assigns, :valid_host_header, nil)
+ end
+ end
+end
diff --git a/test/pleroma/workers/signature_retry_worker_test.exs b/test/pleroma/workers/signature_retry_worker_test.exs
index 94dd5f6c1..3806ecac9 100644
--- a/test/pleroma/workers/signature_retry_worker_test.exs
+++ b/test/pleroma/workers/signature_retry_worker_test.exs
@@ -1,574 +1,635 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.SignatureRetryWorkerTest do
use Pleroma.DataCase, async: false
use Oban.Testing, repo: Pleroma.Repo
import ExUnit.CaptureLog
import Pleroma.Factory
@moduletag capture_log: true
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Signature
alias Pleroma.User
alias Pleroma.Web.ActivityPub.UserView
+ alias Pleroma.Web.Endpoint
alias Pleroma.Web.Federator
alias Pleroma.Workers.SignatureRetryWorker
defp signature_headers_for(%User{} = signer) do
[
- {"host", "local.test"},
+ {"host", "#{URI.parse(Endpoint.url()).host}"},
{"date", "Thu, 25 Jul 2024 13:33:31 GMT"},
{"digest", "SHA-256=fake-digest"},
{"content-type", "application/activity+json"},
{
"signature",
"keyId=\"#{signer.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
}
]
end
defp stub_actor_fetch(%User{} = signer) do
signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured")
Tesla.Mock.mock(fn
%{url: url} when url == signer.ap_id ->
%Tesla.Env{
status: 200,
body: Jason.encode!(signer_json),
headers: HttpRequestMock.activitypub_object_headers()
}
end)
end
defp expect_signature_from(%User{} = signer) do
stub_actor_fetch(signer)
Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> true end)
end
defp enqueue_failed_signature(params, signer) do
Federator.incoming_failed_signature_ap_doc(%{
method: "POST",
req_headers: signature_headers_for(signer),
request_path: "/inbox",
params: params,
query_string: ""
})
end
defp failed_signature_job(params, req_headers, opts \\ []) do
%Oban.Job{
args: %{
"op" => "incoming_failed_signature_ap_doc",
"method" => Keyword.get(opts, :method, "POST"),
"req_headers" => req_headers,
"request_path" => Keyword.get(opts, :request_path, "/inbox"),
"params" => params,
"query_string" => Keyword.get(opts, :query_string, "")
}
}
end
defp assert_mismatched_signature_cancelled(params, signer) do
assert {:ok, oban_job} = enqueue_failed_signature(params, signer)
capture_log([level: :warning], fn ->
assert {:cancel, :actor_signature_mismatch} = SignatureRetryWorker.perform(oban_job)
end)
end
test "Federator preserves request metadata for failed-signature retry jobs" do
params = insert(:note_activity).data
req_headers = [
{"host", "local.test"},
{"signature", "keyId=\"https://one.com/users/alice#main-key\""}
]
assert {:ok, oban_job} =
Federator.incoming_failed_signature_ap_doc(%{
method: "POST",
req_headers: req_headers,
request_path: "/inbox",
params: params,
query_string: "foo=bar"
})
assert oban_job.worker == "Pleroma.Workers.SignatureRetryWorker"
assert %{
"op" => "incoming_failed_signature_ap_doc",
"method" => "POST",
"req_headers" => ^req_headers,
"request_path" => "/inbox",
"params" => ^params,
"query_string" => "foo=bar"
} = oban_job.args
end
test "cancels retry jobs without request metadata" do
params = insert(:note_activity).data
log =
capture_log([level: :warning], fn ->
assert {:cancel, :missing_signature_retry_metadata} =
SignatureRetryWorker.perform(%Oban.Job{
args: %{"op" => "incoming_failed_signature_ap_doc", "params" => params}
})
end)
assert log =~ "Failed-signature inbox retry rejected"
assert log =~ "reason=:missing_signature_retry_metadata"
assert log =~ "payload_actor=#{inspect(params["actor"])}"
assert log =~ "activity_id=#{inspect(params["id"])}"
assert log =~ "type=#{inspect(params["type"])}"
assert log =~ "request_path=nil"
end
test "cancels retry jobs with malformed serialized request headers" do
params = insert(:note_activity).data
log =
capture_log([level: :warning], fn ->
assert {:cancel, :invalid_signature_retry_metadata} =
SignatureRetryWorker.perform(failed_signature_job(params, [["signature"]]))
end)
assert log =~ "Failed-signature inbox retry rejected"
assert log =~ "reason=:invalid_signature_retry_metadata"
assert log =~ "signature_actor=nil"
assert log =~ "request_path=\"/inbox\""
end
test "cancels retry jobs without a signature header" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
params = insert(:note_activity, user: alice).data
log =
capture_log([level: :warning], fn ->
assert {:cancel, :invalid_signature} =
SignatureRetryWorker.perform(
failed_signature_job(params, [{"host", "local.test"}])
)
end)
assert log =~ "Failed-signature inbox retry rejected"
assert log =~ "reason=:invalid_signature"
assert log =~ "payload_actor=#{inspect(params["actor"])}"
assert log =~ "signature_actor=nil"
assert log =~ "request_path=\"/inbox\""
end
test "cancels missing signature before fetching an unavailable payload actor" do
params =
insert(:note_activity).data
|> Map.put("actor", "https://unavailable.example/users/bob")
assert {:cancel, :invalid_signature} =
SignatureRetryWorker.perform(failed_signature_job(params, [{"host", "local.test"}]))
end
test "cancels signer mismatch before fetching an unavailable payload actor" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
params =
insert(:note_activity).data
|> Map.put("actor", "https://unavailable.example/users/bob")
assert {:cancel, :actor_signature_mismatch} =
SignatureRetryWorker.perform(
failed_signature_job(params, signature_headers_for(alice))
)
end
test "cancels retry jobs with a signature header without keyId" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
params = insert(:note_activity, user: alice).data
req_headers = [{"signature", "algorithm=\"rsa-sha256\",signature=\"fake-signature\""}]
assert {:cancel, :invalid_signature} =
SignatureRetryWorker.perform(failed_signature_job(params, req_headers))
end
test "cancels retry jobs with an unparsable signature keyId" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
params = insert(:note_activity, user: alice).data
req_headers = [{"signature", "keyId=\"not an activitypub id\",signature=\"fake-signature\""}]
assert {:cancel, :invalid_signature} =
SignatureRetryWorker.perform(failed_signature_job(params, req_headers))
end
test "cancels when the refetched key still cannot validate the signature" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
create = %{
"type" => "Create",
"actor" => alice.ap_id,
"id" => "https://one.com/activities/invalid-signature-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "https://one.com/objects/invalid-signature-note",
"actor" => alice.ap_id,
"attributedTo" => alice.ap_id,
"content" => "forged post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
stub_actor_fetch(alice)
assert {:ok, oban_job} = enqueue_failed_signature(create, alice)
log =
capture_log([level: :warning], fn ->
assert {:cancel, :invalid_signature} = SignatureRetryWorker.perform(oban_job)
end)
assert log =~ "Failed-signature inbox retry rejected"
assert log =~ "reason=:invalid_signature"
assert log =~ "payload_actor=\"https://one.com/users/alice\""
assert log =~ "signature_actor=\"https://one.com/users/alice\""
assert log =~ "activity_id=\"https://one.com/activities/invalid-signature-create\""
assert log =~ "type=\"Create\""
assert log =~ "request_path=\"/inbox\""
refute Activity.get_by_ap_id(create["id"])
end
+ test "cancels when the Host header does not match Endpoint" do
+ alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
+
+ create = %{
+ "type" => "Create",
+ "actor" => alice.ap_id,
+ "id" => "https://one.com/activities/invalid-signature-create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "object" => %{
+ "type" => "Note",
+ "id" => "https://one.com/objects/invalid-signature-note",
+ "actor" => alice.ap_id,
+ "attributedTo" => alice.ap_id,
+ "content" => "forged post",
+ "published" => "2024-07-25T13:33:31Z",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => []
+ }
+ }
+
+ expect_signature_from(alice)
+
+ headers =
+ [
+ {"host", "invalid.example.com"},
+ {"date", "Thu, 25 Jul 2024 13:33:31 GMT"},
+ {"digest", "SHA-256=fake-digest"},
+ {"content-type", "application/activity+json"},
+ {
+ "signature",
+ "keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
+ }
+ ]
+
+ assert {:ok, oban_job} =
+ Federator.incoming_failed_signature_ap_doc(%{
+ method: "POST",
+ req_headers: headers,
+ request_path: "/inbox",
+ params: create,
+ query_string: ""
+ })
+
+ log =
+ capture_log([level: :warning], fn ->
+ assert {:cancel, :host_header_mismatch} = SignatureRetryWorker.perform(oban_job)
+ end)
+
+ assert log =~ "Failed-signature inbox retry rejected"
+ assert log =~ "reason=:host_header_mismatch"
+ assert log =~ "payload_actor=\"https://one.com/users/alice\""
+ assert log =~ "signature_actor=\"https://one.com/users/alice\""
+ assert log =~ "activity_id=\"https://one.com/activities/invalid-signature-create\""
+ assert log =~ "type=\"Create\""
+ assert log =~ "request_path=\"/inbox\""
+
+ refute Activity.get_by_ap_id(create["id"])
+ end
+
test "processes the activity after refetching a valid matching signature" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
create = %{
"type" => "Create",
"actor" => alice.ap_id,
"id" => "https://one.com/activities/valid-signature-create",
"context" => "https://one.com/contexts/valid-signature-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "https://one.com/objects/valid-signature-note",
"actor" => alice.ap_id,
"attributedTo" => alice.ap_id,
"context" => "https://one.com/contexts/valid-signature-create",
"content" => "valid post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
expect_signature_from(alice)
assert {:ok, oban_job} = enqueue_failed_signature(create, alice)
assert {:ok, %Activity{}} = SignatureRetryWorker.perform(oban_job)
assert Activity.get_by_ap_id(create["id"])
end
test "processes the activity when a real signature validates with a query string" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
create = %{
"type" => "Create",
"actor" => alice.ap_id,
"id" => "https://one.com/activities/valid-query-signature-create",
"context" => "https://one.com/contexts/valid-query-signature-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "https://one.com/objects/valid-query-signature-note",
"actor" => alice.ap_id,
"attributedTo" => alice.ap_id,
"context" => "https://one.com/contexts/valid-query-signature-create",
"content" => "valid signed post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
stub_actor_fetch(alice)
date = "Thu, 25 Jul 2024 13:33:31 GMT"
digest = "SHA-256=fake-digest"
signature =
Signature.sign(alice, %{
"(request-target)" => "post /inbox?foo=bar",
"content-type" => "application/activity+json",
date: date,
digest: digest,
- host: "local.test"
+ host: "#{URI.parse(Endpoint.url()).host}"
})
req_headers = [
- ["host", "local.test"],
+ ["host", "#{URI.parse(Endpoint.url()).host}"],
["date", date],
["digest", digest],
["content-type", "application/activity+json"],
["signature", signature]
]
assert {:ok, %Activity{}} =
SignatureRetryWorker.perform(
failed_signature_job(create, req_headers, query_string: "foo=bar")
)
assert Activity.get_by_ap_id(create["id"])
end
test "cancels when signature actor does not match payload actor" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
note =
insert(:note,
user: bob,
object_local: false,
data: %{"id" => "https://two.com/objects/malicious-update-note"}
)
update = %{
"type" => "Update",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/malicious-update",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => note.data
}
assert_mismatched_signature_cancelled(update, alice)
end
test "cancels signature actor mismatch through Federator-created jobs" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
note =
insert(:note,
user: bob,
object_local: false,
data: %{"id" => "https://two.com/objects/federator-malicious-note"}
)
update = %{
"type" => "Update",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/federator-malicious-update",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => note.data
}
assert_mismatched_signature_cancelled(update, alice)
end
test "cancels signature actor mismatch before processing a forged Create" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
create = %{
"type" => "Create",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/forged-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "https://two.com/objects/forged-note",
"actor" => bob.ap_id,
"attributedTo" => bob.ap_id,
"content" => "forged post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
assert_mismatched_signature_cancelled(create, alice)
end
test "cancels signature actor mismatch when payload actor is embedded" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
create = %{
"type" => "Create",
"actor" => %{"id" => bob.ap_id},
"id" => "https://two.com/activities/embedded-actor-forged-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "https://two.com/objects/embedded-actor-forged-note",
"actor" => bob.ap_id,
"attributedTo" => bob.ap_id,
"content" => "forged post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
assert_mismatched_signature_cancelled(create, alice)
end
test "logs signature actor mismatch retry rejections" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
create = %{
"type" => "Create",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/logged-forged-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => "https://two.com/objects/logged-forged-note",
"actor" => bob.ap_id,
"attributedTo" => bob.ap_id,
"content" => "forged post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
log = assert_mismatched_signature_cancelled(create, alice)
assert log =~ "Failed-signature inbox retry rejected"
assert log =~ "reason=:actor_signature_mismatch"
assert log =~ "payload_actor=\"https://two.com/users/bob\""
assert log =~ "signature_actor=\"https://one.com/users/alice\""
assert log =~ "activity_id=\"https://two.com/activities/logged-forged-create\""
assert log =~ "type=\"Create\""
assert log =~ "request_path=\"/inbox\""
end
test "cancels signature actor mismatch before actually creating a forged post" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
object_id = "https://two.com/objects/actually-forged-note"
create = %{
"type" => "Create",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/actually-forged-create",
"context" => "https://two.com/contexts/actually-forged-create",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => %{
"type" => "Note",
"id" => object_id,
"actor" => bob.ap_id,
"attributedTo" => bob.ap_id,
"context" => "https://two.com/contexts/actually-forged-create",
"content" => "forged post",
"published" => "2024-07-25T13:33:31Z",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => []
}
}
assert_mismatched_signature_cancelled(create, alice)
refute Object.get_by_ap_id(object_id)
end
test "cancels signature actor mismatch before processing a forged Like" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
note = insert(:note)
like = %{
"type" => "Like",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/forged-like",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => note.data["id"]
}
assert_mismatched_signature_cancelled(like, alice)
end
test "cancels signature actor mismatch before actually creating a forged Like" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
note = insert(:note)
like = %{
"type" => "Like",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/actually-forged-like",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => note.data["id"]
}
assert_mismatched_signature_cancelled(like, alice)
refute Activity.get_by_ap_id(like["id"])
end
test "cancels signature actor mismatch before processing a forged Announce" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
note = insert(:note)
announce = %{
"type" => "Announce",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/forged-announce",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => note.data["id"]
}
assert_mismatched_signature_cancelled(announce, alice)
end
test "cancels signature actor mismatch before processing a forged Follow" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
followed = insert(:user)
follow = %{
"type" => "Follow",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/forged-follow",
"to" => [followed.ap_id],
"cc" => [],
"object" => followed.ap_id
}
assert_mismatched_signature_cancelled(follow, alice)
end
test "cancels signature actor mismatch before processing a forged Undo" do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
undo = %{
"type" => "Undo",
"actor" => bob.ap_id,
"id" => "https://two.com/activities/forged-undo",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [],
"object" => "https://two.com/activities/existing-bob-activity"
}
assert_mismatched_signature_cancelled(undo, alice)
end
end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index f010fec33..c01516169 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -1,125 +1,128 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common datastructures and query the data layer.
Finally, if the test case interacts with the database,
it cannot be async. For this reason, every test runs
inside a transaction which is reset at the beginning
of the test unless the test case is marked as async.
"""
use ExUnit.CaseTemplate
alias Pleroma.DataCase
using do
quote do
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
use Pleroma.Tests.Helpers
import Pleroma.Web.Router.Helpers
alias Pleroma.Config
# The default endpoint for testing
@endpoint Pleroma.Web.Endpoint
# Sets up OAuth access with specified scopes
defp oauth_access(scopes, opts \\ []) do
user =
Keyword.get_lazy(opts, :user, fn ->
Pleroma.Factory.insert(:user)
end)
token =
Keyword.get_lazy(opts, :oauth_token, fn ->
Pleroma.Factory.insert(:oauth_token, user: user, scopes: scopes)
end)
conn =
build_conn()
|> assign(:user, user)
|> assign(:token, token)
%{user: user, token: token, conn: conn}
end
defp request_content_type(%{conn: conn}) do
conn = put_req_header(conn, "content-type", "multipart/form-data")
[conn: conn]
end
defp empty_json_response(conn) do
body = response(conn, 204)
response_content_type(conn, :json)
body
end
defp json_response_and_validate_schema(
%{private: %{operation_id: op_id}} = conn,
status
) do
{spec, lookup} = OpenApiSpex.Plug.PutApiSpec.get_spec_and_operation_lookup(conn)
content_type =
conn
|> Plug.Conn.get_resp_header("content-type")
|> List.first()
|> String.split(";")
|> List.first()
status = Plug.Conn.Status.code(status)
unless lookup[op_id].responses[status] do
err = "Response schema not found for #{status} #{conn.method} #{conn.request_path}"
flunk(err)
end
schema = lookup[op_id].responses[status].content[content_type].schema
json = if status == 204, do: empty_json_response(conn), else: json_response(conn, status)
case OpenApiSpex.cast_value(json, schema, spec) do
{:ok, _data} ->
json
{:error, errors} ->
errors =
Enum.map(errors, fn error ->
message = OpenApiSpex.Cast.Error.message(error)
path = OpenApiSpex.Cast.Error.path_to_string(error)
"#{message} at #{path}"
end)
flunk(
"Response does not conform to schema of #{op_id} operation: #{Enum.join(errors, "\n")}\n#{inspect(json)}"
)
end
end
defp json_response_and_validate_schema(conn, _status) do
flunk("Response schema not found for #{conn.method} #{conn.request_path} #{conn.status}")
end
end
end
setup tags do
DataCase.setup_multi_process_mode(tags)
DataCase.setup_streamer(tags)
DataCase.stub_pipeline()
Mox.verify_on_exit!()
+ endpoint = URI.parse(Pleroma.Web.Endpoint.url())
+ conn = Phoenix.ConnTest.build_conn()
+ conn = %{conn | req_headers: [{"host", "#{endpoint.host}:#{endpoint.port}"}]}
- {:ok, conn: Phoenix.ConnTest.build_conn()}
+ {:ok, conn: conn}
end
end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Oct 31, 9:36 AM (20 h, 26 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801226
Default Alt Text
(197 KB)
Attached To
Mode
rPUBE pleroma-upstream
Attached
Detach File
Event Timeline