diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f799a0ed..5b22c4164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 2.11.1 + +### Security + +- Update Bandit to version 1.14.0, which fixes HTTP/1.1, HTTP/2 and WebSocket denial-of-service and request smuggling vulnerabilities +- Update Hackney to version 4.9.1, which fixes unbounded response body buffering and decompression, CA pinning, redirect credential leaks and other vulnerabilities + ## 2.11.0 ### Security diff --git a/changelog.d/actor-containment.security b/changelog.d/actor-containment.security new file mode 100644 index 000000000..58dbca4bc --- /dev/null +++ b/changelog.d/actor-containment.security @@ -0,0 +1 @@ +Reject remote actors whose inbox, shared inbox, followers, following or featured collection, or key id is on another host diff --git a/changelog.d/ap-c2s-oauth-scopes.security b/changelog.d/ap-c2s-oauth-scopes.security new file mode 100644 index 000000000..319d32e5d --- /dev/null +++ b/changelog.d/ap-c2s-oauth-scopes.security @@ -0,0 +1 @@ +Check OAuth token scopes on the ActivityPub C2S endpoints; posting to the outbox with a token now needs the `write` scope diff --git a/changelog.d/attachment-zero-size.fix b/changelog.d/attachment-zero-size.fix new file mode 100644 index 000000000..dc42677c4 --- /dev/null +++ b/changelog.d/attachment-zero-size.fix @@ -0,0 +1 @@ +Do not crash when rendering attachments with zero width or height diff --git a/changelog.d/federation-reject-instances.security b/changelog.d/federation-reject-instances.security new file mode 100644 index 000000000..d79327317 --- /dev/null +++ b/changelog.d/federation-reject-instances.security @@ -0,0 +1 @@ +Do not deliver activities, including public ones and deletes, to instances rejected by SimplePolicy (or not on its accept list), and refuse their signed fetches in authorized fetch mode diff --git a/changelog.d/fetch-id-containment.security b/changelog.d/fetch-id-containment.security new file mode 100644 index 000000000..aee5eca95 --- /dev/null +++ b/changelog.d/fetch-id-containment.security @@ -0,0 +1 @@ +Only accept fetched objects whose id is the URL they were fetched from; for other ids, refetch once from the canonical id on the same host diff --git a/changelog.d/filter-regex-escape.fix b/changelog.d/filter-regex-escape.fix new file mode 100644 index 000000000..e6e011e3a --- /dev/null +++ b/changelog.d/filter-regex-escape.fix @@ -0,0 +1 @@ +Treat filter phrases as literal text; a phrase like "(" no longer breaks timeline and notification queries, and phrases that start or end with punctuation (like "#tag") now match diff --git a/changelog.d/filter-whole-word.fix b/changelog.d/filter-whole-word.fix new file mode 100644 index 000000000..a831a74cb --- /dev/null +++ b/changelog.d/filter-whole-word.fix @@ -0,0 +1 @@ +Filters with whole_word set to false now also match inside words diff --git a/changelog.d/get-actor-crash.fix b/changelog.d/get-actor-crash.fix new file mode 100644 index 000000000..591ac3ba2 --- /dev/null +++ b/changelog.d/get-actor-crash.fix @@ -0,0 +1 @@ +Reject fetched objects whose author has no id instead of crashing diff --git a/changelog.d/mastodon-quote-entity.add b/changelog.d/mastodon-quote-entity.add new file mode 100644 index 000000000..dc9eccd97 --- /dev/null +++ b/changelog.d/mastodon-quote-entity.add @@ -0,0 +1 @@ +Add the Mastodon quote entity (`quote` with `state` and `quoted_status`) to statuses, so Mastodon 4.4+ clients show quote posts diff --git a/changelog.d/media-proxy-network-path.security b/changelog.d/media-proxy-network-path.security new file mode 100644 index 000000000..681d4742e --- /dev/null +++ b/changelog.d/media-proxy-network-path.security @@ -0,0 +1 @@ +Proxy media given as network-path URLs (//host/path), so remote HTML cannot load images directly diff --git a/changelog.d/mention-notify-access.security b/changelog.d/mention-notify-access.security new file mode 100644 index 000000000..2f1830079 --- /dev/null +++ b/changelog.d/mention-notify-access.security @@ -0,0 +1 @@ +Do not notify tagged users who cannot see the post diff --git a/changelog.d/notification-filter-nil-content.fix b/changelog.d/notification-filter-nil-content.fix new file mode 100644 index 000000000..56b3ad6d1 --- /dev/null +++ b/changelog.d/notification-filter-nil-content.fix @@ -0,0 +1 @@ +Do not crash when creating notifications for posts without content while the user has filters diff --git a/changelog.d/oauth-profile-scope.add b/changelog.d/oauth-profile-scope.add new file mode 100644 index 000000000..e7e022baa --- /dev/null +++ b/changelog.d/oauth-profile-scope.add @@ -0,0 +1 @@ +Support the Mastodon `profile` OAuth scope, which only gives the public account from verify_credentials diff --git a/changelog.d/oauth-tokens-scopes.security b/changelog.d/oauth-tokens-scopes.security new file mode 100644 index 000000000..653b7a059 --- /dev/null +++ b/changelog.d/oauth-tokens-scopes.security @@ -0,0 +1 @@ +Require the read:security and write:security scopes to list and revoke OAuth tokens (HTTP Basic auth no longer works for these endpoints) diff --git a/changelog.d/object-age-non-public.security b/changelog.d/object-age-non-public.security new file mode 100644 index 000000000..aee358d38 --- /dev/null +++ b/changelog.d/object-age-non-public.security @@ -0,0 +1 @@ +ObjectAgePolicy no longer makes old DMs and followers-only posts public diff --git a/changelog.d/release-2.11.0.skip b/changelog.d/rate-limiter-test-flake.skip similarity index 100% rename from changelog.d/release-2.11.0.skip rename to changelog.d/rate-limiter-test-flake.skip diff --git a/changelog.d/refetch-containment.security b/changelog.d/refetch-containment.security new file mode 100644 index 000000000..bfd118ab1 --- /dev/null +++ b/changelog.d/refetch-containment.security @@ -0,0 +1 @@ +Do not let a refetched object (such as a poll) change its id or author diff --git a/changelog.d/release-2.11.1.skip b/changelog.d/release-2.11.1.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/remove-dead-pleroma-api-controllers.skip b/changelog.d/remove-dead-pleroma-api-controllers.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/user-fetch-display-url.fix b/changelog.d/user-fetch-display-url.fix new file mode 100644 index 000000000..fcde4ecf4 --- /dev/null +++ b/changelog.d/user-fetch-display-url.fix @@ -0,0 +1 @@ +Update the known user instead of failing when an actor is fetched from a display URL diff --git a/changelog.d/user-query-is-active.fix b/changelog.d/user-query-is-active.fix new file mode 100644 index 000000000..8f28d8a0e --- /dev/null +++ b/changelog.d/user-query-is-active.fix @@ -0,0 +1 @@ +Fix deactivated users being counted in followers collections and getting admin emails, because User.Query ignored the is_active key diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 0460caf26..4865ff0aa 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -177,8 +177,8 @@ To add configuration to your config file, you can copy it from the base config. * `media_removal`: List of instances to strip media attachments from and the reason for doing so. * `media_nsfw`: List of instances to tag all media as NSFW (sensitive) from and the reason for doing so. * `federated_timeline_removal`: List of instances to remove from the Federated Timeline (aka The Whole Known Network) and the reason for doing so. -* `reject`: List of instances to reject activities (except deletes) from and the reason for doing so. -* `accept`: List of instances to only accept activities (except deletes) from and the reason for doing so. +* `reject`: List of instances to reject activities (except deletes) from and the reason for doing so. Activities are also not delivered to these instances, and signed fetches from them are refused in `authorized_fetch_mode`. +* `accept`: List of instances to only accept activities (except deletes) from and the reason for doing so. If set, activities are also only delivered to these instances, and signed fetches from others are refused in `authorized_fetch_mode`. * `followers_only`: Force posts from the given instances to be visible by followers only and the reason for doing so. * `report_removal`: List of instances to reject reports from and the reason for doing so. * `avatar_removal`: List of instances to strip avatars from and the reason for doing so. diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 194c98e3f..45498defa 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -303,6 +303,10 @@ Additional fields are returned in the response: - `id`: The primary key of this token in Pleroma's database. - `me` (user tokens only): The ActivityPub ID of the user who owns the token. +### `profile` scope + +As in Mastodon 4.3, a token with the `profile` scope can call `GET /api/v1/accounts/verify_credentials` and no other Mastodon API endpoint. Apps can use it to log users in with their fediverse account. Unless the token also has `read` or `read:accounts`, Pleroma returns only public data: unlike Mastodon, there is no `source.privacy`, no `follow_requests_count`, and none of the private `pleroma` fields such as `email`, `settings_store` and `chat_token`. + ## Account Registration `POST /api/v1/accounts` diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md index b19523bce..5627252e8 100644 --- a/docs/development/API/pleroma_api.md +++ b/docs/development/API/pleroma_api.md @@ -780,6 +780,7 @@ Emoji reactions work a lot like favourites do. They make it possible to react to ### Retrieve a list of active sessions for the user * Method: `GET` * Authentication: required +* OAuth scope: `read:security` * Params: none * Response: JSON * Example response: @@ -808,8 +809,9 @@ Emoji reactions work a lot like favourites do. They make it possible to react to ### Revoke a user session by its ID * Method: `DELETE` * Authentication: required +* OAuth scope: `write:security` * Params: none -* Response: HTTP 200 on success, 500 on error +* Response: HTTP 201 on success, 500 on error ## `/api/v1/pleroma/settings/:app` ### Gets settings for some application diff --git a/lib/mix/tasks/pleroma/email.ex b/lib/mix/tasks/pleroma/email.ex index 37272c124..76725f65f 100644 --- a/lib/mix/tasks/pleroma/email.ex +++ b/lib/mix/tasks/pleroma/email.ex @@ -33,7 +33,7 @@ defmodule Mix.Tasks.Pleroma.Email do Pleroma.User.Query.build(%{ local: true, - is_active: true, + deactivated: false, is_confirmed: false, invisible: false }) diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 929fa1717..1035f2fce 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -362,7 +362,7 @@ defmodule Mix.Tasks.Pleroma.User do Pleroma.User.Query.build(%{ local: true, - is_active: true, + deactivated: false, is_moderator: false, is_admin: false, invisible: false @@ -380,7 +380,7 @@ defmodule Mix.Tasks.Pleroma.User do Pleroma.User.Query.build(%{ local: true, - is_active: true, + deactivated: false, is_moderator: false, is_admin: false, invisible: false diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 77ed64d4f..fe3fb1c27 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -208,19 +208,30 @@ defmodule Pleroma.Filter do end def compose_regex([_ | _] = filters, format) do + boundary = if format == :postgres, do: "\\y", else: "\\b" + phrases = filters - |> Enum.map(& &1.phrase) + |> Enum.map(&phrase_regex(&1, boundary)) |> Enum.join("|") case format do - :postgres -> - "\\y(#{phrases})\\y" - - :re -> - ~r/\b#{phrases}\b/i + :postgres -> "(#{phrases})" + :re -> Regex.compile!("(?:#{phrases})", "iu") end end def compose_regex(_, _), do: nil + + # Phrases are literal text. Like Mastodon, whole-word filters only get a + # word boundary where the phrase starts or ends with a word character. + defp phrase_regex(%__MODULE__{phrase: phrase, whole_word: false}, _boundary), + do: Regex.escape(phrase) + + defp phrase_regex(%__MODULE__{phrase: phrase}, boundary) do + prefix = if String.match?(phrase, ~r/^\w/u), do: boundary, else: "" + suffix = if String.match?(phrase, ~r/\w$/u), do: boundary, else: "" + + prefix <> Regex.escape(phrase) <> suffix + end end diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index c230cea46..ca9f5368d 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -79,8 +79,14 @@ defmodule Pleroma.HTTP do adapter, adapter_opts ) + |> drop_partial_body() end + # Hackney 4.9 returns the bytes read before the peer closed. No caller wants + # them, and callers that cache errors would keep them. + defp drop_partial_body({:error, {:closed, body}}) when is_binary(body), do: {:error, :closed} + defp drop_partial_body(result), do: result + @spec request(Client.t(), keyword()) :: {:ok, Env.t()} | {:error, any()} def request(client, request), do: Tesla.request(client, request) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index afd750823..6e4a97a25 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -821,6 +821,9 @@ defmodule Pleroma.Notification do object.data["actor"] == user.ap_id -> false + not is_binary(object.data["content"]) -> + false + not is_nil(regex = Pleroma.Filter.compose_regex(user, :re)) -> Regex.match?(regex, object.data["content"]) diff --git a/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index c4cc1f014..531100cbb 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -44,6 +44,9 @@ defmodule Pleroma.Object.Containment do id end + # remote data can carry any shape here; callers treat nil as "no actor" + def get_actor(_), do: nil + def get_object(%{"object" => id}) when is_binary(id) do id end @@ -108,8 +111,46 @@ defmodule Pleroma.Object.Containment do def contain_origin_from_id(_id, _data), do: :error + @doc """ + Checks that a fetched document's id is the URL it was fetched from + (after redirects). + + Fragments, a trailing slash, the case of the host and differences + that our URL encoding removes before a request are ignored. + Documents without an id never match. + + Based on Akkoma's `contain_id_to_fetch/2`. + """ + def contain_id_to_fetch(url, %{"id" => id}) when is_binary(url) and is_binary(id) do + if normalize_uri(url) == normalize_uri(id), do: :ok, else: :error + end + + def contain_id_to_fetch(_url, _data), do: :error + + defp normalize_uri(uri) do + uri = uri |> Pleroma.Utils.URIEncoding.encode_url() |> URI.parse() + + %URI{ + uri + | fragment: nil, + host: uri.host && String.downcase(uri.host, :ascii), + path: uri.path && String.replace_suffix(uri.path, "/", "") + } + |> URI.to_string() + end + def contain_child(%{"object" => %{"id" => id, "attributedTo" => _} = object}), do: contain_origin(id, object) def contain_child(_), do: :ok + + @doc "Checks that two URIs are on the same host. URIs without a host never match." + def same_origin(id, other_id) when is_binary(id) and is_binary(other_id) do + case {URI.parse(id), URI.parse(other_id)} do + {%URI{host: host}, %URI{host: host}} when is_binary(host) and host != "" -> :ok + _ -> :error + end + end + + def same_origin(_id, _other_id), do: :error end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 084d44d5e..706ee317c 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Federator @@ -46,9 +47,14 @@ defmodule Pleroma.Object.Fetcher do end end - def refetch_object(%Object{data: %{"id" => id}} = object) do + # The stored id is canonical, so the refetch must not redirect, and the + # remote must not swap in another object or author. + # Based on Akkoma 9061d148b and fee57eb37. + def refetch_object(%Object{data: %{"id" => id} = data} = object) do with {:local, false} <- {:local, Object.local?(object)}, - {:ok, new_data} <- fetch_and_contain_remote_object_from_id(id), + {:ok, new_data} <- fetch_and_contain(id, true), + {:id, true} <- {:id, new_data["id"] == id}, + {:actor, true} <- {:actor, actor_of(new_data) == actor_of(data)}, {:ok, object} <- reinject_object(object, new_data) do {:ok, object} else @@ -57,6 +63,8 @@ defmodule Pleroma.Object.Fetcher do end end + defp actor_of(data), do: CommonFixes.fix_actor(data)["actor"] + @typep fetcher_errors :: :error | :reject | :allowed_depth | :fetch | :containment | :transmogrifier @@ -140,17 +148,39 @@ defmodule Pleroma.Object.Fetcher do def fetch_and_contain_remote_object_from_id(%{"id" => id}), do: fetch_and_contain_remote_object_from_id(id) - def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do + def fetch_and_contain_remote_object_from_id(id) when is_binary(id), + do: fetch_and_contain(id, false) + + def fetch_and_contain_remote_object_from_id(_id), + do: {:error, "id must be a string"} + + # The fetched document's id must be the URL it came from. Display URLs + # (e.g. Mastodon's /@user/123) serve the object under another id, so + # if the id differs we refetch once from that id, which has to be on the + # same host, must not redirect, and must then match exactly. + # Based on Akkoma 8684964c5 and 940792f8b. + defp fetch_and_contain(id, canonical?) do Logger.debug("Fetching object #{id} via AP") with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, {_, true} <- {:mrf, MRF.id_filter(id)}, {_, :ok} <- {:local_fetch, Containment.contain_local_fetch(id)}, - {:ok, body} <- get_object(id), + {:ok, final_url, body} <- get_object(id), + {_, true} <- {:redirect, not canonical? or same_url?(final_url, id)}, {:ok, data} <- safe_json_decode(body), - :ok <- Containment.contain_origin_from_id(id, data) do + :ok <- Containment.contain_origin_from_id(id, data), + {_, _, :ok} <- {:id, data, Containment.contain_id_to_fetch(final_url, data)} do {:ok, data} else + {:id, %{"id" => canonical_id}, :error} when is_binary(canonical_id) and not canonical? -> + fetch_and_contain(canonical_id, true) + + {:id, _, :error} -> + {:error, :id_mismatch} + + {:redirect, false} -> + {:error, :canonical_id_redirect} + {:scheme, _} -> {:error, "Unsupported URI scheme"} @@ -168,8 +198,7 @@ defmodule Pleroma.Object.Fetcher do end end - def fetch_and_contain_remote_object_from_id(_id), - do: {:error, "id must be a string"} + defp same_url?(url, other), do: Containment.contain_id_to_fetch(url, %{"id" => other}) == :ok defp check_crossdomain_redirect(final_host, _original_url) when is_nil(final_host) do {:cross_domain_redirect, false} @@ -195,7 +224,7 @@ defmodule Pleroma.Object.Fetcher do with {:cross_domain_redirect, false} <- check_crossdomain_redirect(remote_host, id), {_, content_type} <- List.keyfind(headers, "content-type", 0), {:ok, _media_type} <- verify_content_type(content_type) do - {:ok, body} + {:ok, if(final_url in [nil, ""], do: id, else: final_url), body} else {:cross_domain_redirect, true} -> {:error, {:cross_domain_redirect, true}} @@ -209,7 +238,7 @@ defmodule Pleroma.Object.Fetcher do case List.keyfind(headers, "content-type", 0) do {_, content_type} -> case verify_content_type(content_type) do - {:ok, _} -> {:ok, body} + {:ok, _} -> {:ok, id, body} error -> error end diff --git a/lib/pleroma/tesla/middleware/max_body.ex b/lib/pleroma/tesla/middleware/max_body.ex index 7e74f0b56..04b1b620d 100644 --- a/lib/pleroma/tesla/middleware/max_body.ex +++ b/lib/pleroma/tesla/middleware/max_body.ex @@ -7,16 +7,14 @@ defmodule Pleroma.Tesla.Middleware.MaxBody do Enforces the `:max_body` adapter option for every adapter. `Pleroma.HTTP.Adapter.Gun` applies the limit as it accumulates a `:plain` - body, so an oversized response is abandoned mid-transfer. Hackney cannot: - since 4.0 it ignores `with_body` and always hands back a fully read binary - (`hackney:request/5`), so `Tesla.Adapter.Hackney` never takes the branch that - would honour `:max_body`. Without this middleware the option is silently - unenforced there. + body, so an oversized response is abandoned mid-transfer. Since 4.9 Hackney + does the same with the `max_body` request option. Before that it ignored + `with_body` and always handed back a fully read binary (`hackney:request/5`), + so `Tesla.Adapter.Hackney` never took the branch that would honour + `:max_body`. - The check therefore bounds what callers are handed, and refuses up front when - `content-length` already announces too much. It cannot bound peak memory on - the hackney path, because the adapter has read the body before any middleware - runs. + The check bounds what callers are handed, whatever the adapter does, and + refuses up front when `content-length` already announces too much. Streaming bodies are left alone: neither adapter limits those, and the caller that owns the stream is the one that has to drain and release it. diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 3adc90c67..c0566c63d 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -969,7 +969,7 @@ defmodule Pleroma.User do candidates = Config.get([:instance, :autofollowed_nicknames]) autofollowed_users = - User.Query.build(%{nickname: candidates, local: true, is_active: true}) + User.Query.build(%{nickname: candidates, local: true, deactivated: false}) |> Repo.all() follow_all(user, autofollowed_users) @@ -1401,7 +1401,7 @@ defmodule Pleroma.User do @spec get_followers_query(User.t(), pos_integer() | nil) :: Ecto.Query.t() def get_followers_query(%User{} = user, nil) do - User.Query.build(%{followers: user, is_active: true}) + User.Query.build(%{followers: user, deactivated: false}) end def get_followers_query(%User{} = user, page) do @@ -1476,7 +1476,7 @@ defmodule Pleroma.User do |> where([u], not u.hide_follows) |> select([u], u.id) - User.Query.build(%{is_active: true}) + User.Query.build(%{deactivated: false}) |> where([u], u.id not in ^[user.id, current_user.id]) |> join(:inner, [u], r in FollowingRelationship, as: :followers_relationships, @@ -1611,7 +1611,7 @@ defmodule Pleroma.User do @spec get_users_from_set([String.t()], keyword()) :: [User.t()] def get_users_from_set(ap_ids, opts \\ []) do local_only = Keyword.get(opts, :local_only, true) - criteria = %{ap_id: ap_ids, is_active: true} + criteria = %{ap_id: ap_ids, deactivated: false} criteria = if local_only, do: Map.put(criteria, :local, true), else: criteria User.Query.build(criteria) @@ -1622,7 +1622,7 @@ defmodule Pleroma.User do def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do to = [actor | to] - query = User.Query.build(%{recipients_from_activity: to, local: true, is_active: true}) + query = User.Query.build(%{recipients_from_activity: to, local: true, deactivated: false}) query |> Repo.all() @@ -2473,7 +2473,7 @@ defmodule Pleroma.User do @spec all_superusers() :: [User.t()] def all_superusers do - User.Query.build(%{super_users: true, local: true, is_active: true}) + User.Query.build(%{super_users: true, local: true, deactivated: false}) |> Repo.all() end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1279e0cb4..b997e7831 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Upload alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.ActivityPub.ObjectValidators.UserValidator alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Streamer alias Pleroma.Web.WebFinger @@ -1924,6 +1925,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def user_data_from_user_object(data, additional \\ []) do with {:ok, data} <- MRF.filter(data), + {:ok, data, _} <- UserValidator.validate(data, []), {:ok, data} <- object_to_user_data(data, additional) do {:ok, data} else @@ -2053,11 +2055,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def enqueue_pin_fetches(_), do: nil def make_user_from_ap_id(ap_id, additional \\ []) do - user = User.get_cached_by_ap_id(ap_id) - with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do enqueue_pin_fetches(data) + # the fetched actor's id differs from ap_id when it was fetched from a display URL + user = User.get_cached_by_ap_id(data.ap_id) + if user do user |> User.remote_user_changeset(data) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 38b8b8bfc..e9b0a0d6d 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -22,8 +22,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Web.ControllerHelper alias Pleroma.Web.Endpoint alias Pleroma.Web.Federator + alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug alias Pleroma.Web.Plugs.FederatingPlug + alias Pleroma.Web.Plugs.OAuthScopesPlug require Logger @@ -46,6 +48,24 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do when action in [:read_inbox, :update_outbox, :whoami, :upload_media] ) + # Checks scopes only for OAuth tokens (signatures and Basic auth already skip the check). + plug(:check_token_scopes, %{scopes: ["read:accounts"]} when action == :whoami) + plug(:check_token_scopes, %{scopes: ["read:statuses"]} when action == :read_inbox) + plug(:check_token_scopes, %{scopes: ["write"]} when action == :update_outbox) + plug(:check_token_scopes, %{scopes: ["write:media"]} when action == :upload_media) + + plug( + :check_token_scopes, + %{fallback: :proceed_unauthenticated, scopes: ["read:statuses"]} + when action in [:outbox, :object_replies] + ) + + plug( + :check_token_scopes, + %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} + when action in [:followers, :following] + ) + plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:upload_media]) plug( @@ -57,6 +77,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do plug(:log_inbox_metadata when action in [:inbox]) plug(:relay_active? when action in [:relay]) + defp check_token_scopes(%{assigns: %{token: %Token{}}} = conn, opts), + do: OAuthScopesPlug.call(conn, opts) + + defp check_token_scopes(conn, _), do: conn + defp relay_active?(conn, _) do if Pleroma.Config.get([:instance, :allow_relay]) do conn diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 51ab476b7..2496d1858 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -164,6 +164,39 @@ defmodule Pleroma.Web.ActivityPub.MRF do Enum.any?(domains, fn domain -> Regex.match?(domain, host) end) end + @doc """ + Returns a function that is false for a host if SimplePolicy is enabled and + rejects it, or has an accept list without it. Used for deliveries and signed + fetches, not only for incoming activities. Build it once and reuse it: it + compiles the instance lists. + """ + @spec instance_filter() :: (String.t() | nil -> boolean()) + def instance_filter do + if Pleroma.Web.ActivityPub.MRF.SimplePolicy in get_policies() do + reject = simple_policy_domains(:reject) + accept = simple_policy_domains(:accept) + + fn + host when is_binary(host) -> + not subdomain_match?(reject, host) and (accept == [] or subdomain_match?(accept, host)) + + _ -> + false + end + else + &is_binary/1 + end + end + + @spec instance_allowed?(String.t() | nil) :: boolean() + def instance_allowed?(host), do: instance_filter().(host) + + defp simple_policy_domains(key) do + Pleroma.Config.get([:mrf_simple, key], []) + |> instance_list_from_tuples() + |> subdomains_regex() + end + @spec instance_list_from_tuples([{String.t(), String.t()}]) :: [String.t()] def instance_list_from_tuples(list) do Enum.map(list, fn {instance, _} -> instance end) diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index 00f4cf84b..25ae02f60 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -36,16 +36,34 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do end end + @spec delete_and_count(list(), term()) :: {integer(), list()} + defp delete_and_count(list, element), do: delete_and_count(list, element, {0, [], list}) + + defp delete_and_count([], _element, {0, _nlist, olist}), do: {0, olist} + defp delete_and_count([], _element, {count, nlist, _olist}), do: {count, Enum.reverse(nlist)} + + defp delete_and_count([h | r], h, {count, nlist, olist}), + do: delete_and_count(r, h, {count + 1, nlist, olist}) + + defp delete_and_count([h | r], element, {count, nlist, olist}), + do: delete_and_count(r, element, {count, [h | nlist], olist}) + + defp insert_if_needed(list, oldcount, element) do + if oldcount <= 0 || Enum.member?(list, element) do + list + else + [element | list] + end + end + defp check_delist(activity, actions) do if :delist in actions do with %User{} = user <- User.get_cached_by_ap_id(activity["actor"]) do - to = - List.delete(activity["to"] || [], Pleroma.Constants.as_public()) ++ - [user.follower_address] + {pubcnt, to} = delete_and_count(activity["to"] || [], Pleroma.Constants.as_public()) + {flwcnt, cc} = delete_and_count(activity["cc"] || [], user.follower_address) - cc = - List.delete(activity["cc"] || [], user.follower_address) ++ - [Pleroma.Constants.as_public()] + cc = insert_if_needed(cc, pubcnt, Pleroma.Constants.as_public()) + to = insert_if_needed(to, flwcnt, user.follower_address) activity = activity @@ -67,8 +85,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do defp check_strip_followers(activity, actions) do if :strip_followers in actions do with %User{} = user <- User.get_cached_by_ap_id(activity["actor"]) do - to = List.delete(activity["to"] || [], user.follower_address) - cc = List.delete(activity["cc"] || [], user.follower_address) + {_, to} = delete_and_count(activity["to"] || [], user.follower_address) + {_, cc} = delete_and_count(activity["cc"] || [], user.follower_address) activity = activity diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 7045fe90f..b58d1c75a 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -333,12 +333,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do %{ key: :reject, description: - "List of instances to reject activities from (except deletes) and the reason for doing so" + "List of instances to reject activities from (except deletes) and the reason for doing so. We also do not deliver to them, and refuse their signed fetches in authorized fetch mode" }, %{ key: :accept, description: - "List of instances to only accept activities from (except deletes) and the reason for doing so" + "List of instances to only accept activities from (except deletes) and the reason for doing so. If set, we also only deliver to them, and refuse signed fetches from others in authorized fetch mode" }, %{ key: :followers_only, diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 0254d6dc1..468298a4d 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating - import Pleroma.Constants, only: [activity_types: 0, object_types: 0] + import Pleroma.Constants, only: [activity_types: 0, actor_types: 0, object_types: 0] alias Pleroma.Activity alias Pleroma.EctoType.ActivityPub.ObjectValidators @@ -37,6 +37,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.UserValidator @impl true def validate(object, meta) @@ -226,7 +227,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do end def validate(%{"type" => type} = object, meta) when type == "Update" do - with {:ok, object} <- + with :ok <- validate_updated_actor(object, meta), + {:ok, object} <- object |> UpdateValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do @@ -247,6 +249,19 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do def validate(o, m), do: {:error, {:validator_not_set, {o, m}}} + defp validate_updated_actor(%{"object" => %{"type" => type} = actor}, meta) + when type in actor_types() do + with {_, false} <- {:local, Access.get(meta, :local, false)}, + {:ok, _, _} <- UserValidator.validate(actor, meta) do + :ok + else + {:local, true} -> :ok + error -> error + end + end + + defp validate_updated_actor(_, _), do: :ok + def cast_and_apply_and_stringify_with_history(object) do do_separate_with_history(object, fn object -> with {:ok, object_data} <- cast_and_apply(object), diff --git a/lib/pleroma/web/activity_pub/object_validators/user_validator.ex b/lib/pleroma/web/activity_pub/object_validators/user_validator.ex new file mode 100644 index 000000000..a368ccc16 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/user_validator.ex @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidator do + @moduledoc """ + Checks that a remote actor document only points at its own host. + + We deliver to the inbox and shared inbox, use the collections for + addressing and visibility checks, and map signature key ids back to + the actor. If an actor could point any of these at another host, it + could redirect our signed deliveries or claim another actor's + collections. Optional fields are only checked when present; null and + empty strings count as missing. + + Based on Akkoma's UserValidator. + """ + + @behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating + + alias Pleroma.Object.Containment + + @collections ["inbox", "followers", "following", "featured"] + + @impl true + def validate(%{"id" => id} = data, meta) when is_binary(id) do + fields = + Enum.map(@collections, &{&1, data[&1]}) ++ + [ + {"sharedInbox", get_in_map(data, ["endpoints", "sharedInbox"])}, + {"publicKey", get_in_map(data, ["publicKey", "id"])} + ] + + case Enum.find(fields, fn {_, value} -> not same_origin?(id, value) end) do + nil -> {:ok, data, meta} + {field, _} -> {:error, "#{field} is not on the same host as the actor"} + end + end + + def validate(_data, _meta), do: {:error, "Actor has no id"} + + defp same_origin?(_id, value) when value in [nil, ""], do: true + defp same_origin?(id, %{"id" => value}), do: same_origin?(id, value) + defp same_origin?(id, value), do: Containment.same_origin(id, value) == :ok + + defp get_in_map(data, [key | rest]) do + case data do + %{^key => value} when rest == [] -> value + %{^key => value} -> get_in_map(value, rest) + _ -> nil + end + end +end diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index e3c4e01a4..17fae2a86 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.Publisher.Prepared alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Workers.PublisherWorker @@ -221,18 +222,25 @@ defmodule Pleroma.Web.ActivityPub.Publisher do end end - def should_federate?(nil, _), do: false - def should_federate?(_, true), do: true + def should_federate?(inbox, public), do: federation_filter(public).(inbox) - def should_federate?(inbox, _) do - %{host: host} = URI.parse(inbox) + # Compiles the instance lists once, for filtering many inboxes. + defp federation_filter(public) do + allowed? = MRF.instance_filter() - quarantined_instances = + quarantined = Config.get([:instance, :quarantined_instances], []) - |> Pleroma.Web.ActivityPub.MRF.instance_list_from_tuples() - |> Pleroma.Web.ActivityPub.MRF.subdomains_regex() + |> MRF.instance_list_from_tuples() + |> MRF.subdomains_regex() - !Pleroma.Web.ActivityPub.MRF.subdomain_match?(quarantined_instances, host) + fn + nil -> + false + + inbox -> + %{host: host} = URI.parse(inbox) + allowed?.(host) and (public or not MRF.subdomain_match?(quarantined, host)) + end end @spec recipients(User.t(), Activity.t()) :: [[User.t()]] @@ -312,7 +320,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do def publish(%User{} = actor, %{data: %{"bcc" => bcc}} = activity) when is_list(bcc) and bcc != [] do - public = public?(activity) + should_federate? = activity |> public?() |> federation_filter() [priority_recipients, recipients] = recipients(actor, activity) @@ -324,7 +332,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do determine_inbox(activity, user) end) |> Enum.uniq() - |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) + |> Enum.filter(should_federate?) |> Instances.filter_reachable() end) @@ -360,6 +368,8 @@ defmodule Pleroma.Web.ActivityPub.Publisher do Relay.publish(activity) end + should_federate? = federation_filter(public) + [priority_inboxes, inboxes] = recipients(actor, activity) |> Enum.map(fn recipients -> @@ -368,7 +378,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do determine_inbox(activity, user) end) |> Enum.uniq() - |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) + |> Enum.filter(should_federate?) end) inboxes = inboxes -- priority_inboxes diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 07eb9328b..5427ef0f7 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -48,7 +48,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do description: "Test to make sure that the user token works.", summary: "Verify account credentials", operationId: "AccountController.verify_credentials", - security: [%{"oAuth" => ["read:accounts"]}], + security: [%{"oAuth" => ["read:accounts"]}, %{"oAuth" => ["profile"]}], responses: %{ 200 => Operation.response("Account", "application/json", Account) } diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 63e9eebf0..cab019f37 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -234,6 +234,29 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do type: :integer, description: "How many statuses quoted this status." }, + quote: %Schema{ + type: :object, + nullable: true, + description: + "The status being quoted, if any (Mastodon 4.4+). Quoted statuses only have `quoted_status_id`.", + properties: %{ + state: %Schema{ + type: :string, + enum: ["accepted", "unauthorized", "deleted"], + description: "Whether the quoted status can be shown" + }, + quoted_status: %Schema{ + allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], + nullable: true, + description: "The quoted status, if it can be shown" + }, + quoted_status_id: %Schema{ + type: :string, + nullable: true, + description: "ID of the quoted status, if it can be shown" + } + } + }, reblog: %Schema{ allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], nullable: true, diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 255803c14..b66e0ace6 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -435,13 +435,56 @@ defmodule Pleroma.Web.CommonAPI.Utils do %{} end - tagged_mentions = maybe_extract_mentions(object_data) + tagged_mentions = + maybe_extract_mentions(object_data) + |> filter_tags_to_qualified(activity) recipients ++ tagged_mentions end def maybe_notify_mentioned_recipients(recipients, _), do: recipients + # Filter tagged mentions to ensure they can actually access the status + defp filter_tags_to_qualified(tagged_mentions, %Activity{ + recipients: recipients, + data: data, + actor: actor + }) do + recipients = + if recipients && recipients != [], + do: recipients, + else: + (data["to"] || []) ++ (data["cc"] || []) ++ (data["bto"] || []) ++ (data["bcc"] || []) + + cond do + recipients == [] -> + [] + + Pleroma.Constants.as_public() in recipients -> + tagged_mentions + + true -> + author = User.get_cached_by_ap_id(actor) + to_followers = match?(%User{}, author) and author.follower_address in recipients + + Enum.filter(tagged_mentions, fn tagged -> + cond do + tagged in recipients -> + true + + to_followers -> + case User.get_cached_by_ap_id(tagged) do + %User{} = tagged_user -> User.following?(tagged_user, author) + _ -> false + end + + true -> + false + end + end) + end + end + def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do user diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 631102843..609eeb8cd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -24,6 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter alias Pleroma.Web.Registration @@ -49,8 +50,12 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OAuthScopesPlug, - %{scopes: ["read:accounts"]} - when action in [:verify_credentials, :endorsements, :own_endorsements] + %{scopes: ["read:accounts", "profile"]} when action == :verify_credentials + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:accounts"]} when action in [:endorsements, :own_endorsements] ) plug( @@ -161,7 +166,18 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "GET /api/v1/accounts/verify_credentials" - def verify_credentials(%{assigns: %{user: user}} = conn, _) do + def verify_credentials(%{assigns: %{user: user, token: %Token{scopes: scopes}}} = conn, _) do + if OAuthScopesPlug.filter_descendants(["read:accounts"], scopes) == [] do + # The profile scope only identifies the user: show what anyone can see. + render(conn, "show.json", user: user, skip_visibility_check: true) + else + render_own_account(conn, user) + end + end + + def verify_credentials(%{assigns: %{user: user}} = conn, _), do: render_own_account(conn, user) + + defp render_own_account(conn, user) do chat_token = Phoenix.Token.sign(conn, "user socket", user.id) render(conn, "show.json", diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 4625be47c..42cbc1590 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -455,6 +455,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do language: get_language(object), emojis: build_emojis(object.data["emoji"]), quotes_count: object.data["quotesCount"] || 0, + quote: build_quote(object, quote_activity, quote_post, opts), pleroma: %{ local: activity.local, conversation_id: get_context_id(activity), @@ -675,7 +676,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do def render("attachment_meta.json", %{ attachment: %{"url" => [%{"width" => width, "height" => height} | _]} }) - when is_integer(width) and is_integer(height) do + when is_integer(width) and is_integer(height) and width > 0 and height > 0 do %{ original: %{ width: width, @@ -691,7 +692,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do %{ancestors: ancestors, descendants: descendants} = activities |> Enum.reverse() - |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end) + |> Enum.group_by(fn %{id: id} -> + if id < activity.id, do: :ancestors, else: :descendants + end) |> Map.put_new(:ancestors, []) |> Map.put_new(:descendants, []) @@ -742,6 +745,26 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do end end + # Mastodon (4.4+) quote entity. Quoted statuses get a shallow quote that only + # has the id, as quotes are only rendered one level deep. + defp build_quote(%Object{data: %{"quoteUrl" => url}}, quote_activity, quote_post, opts) + when is_binary(url) and url != "" do + state = + cond do + is_nil(quote_activity) -> "deleted" + visible_for_user?(quote_activity, opts[:for]) -> "accepted" + true -> "unauthorized" + end + + if opts[:show_quote] == false do + %{state: state, quoted_status_id: if(state == "accepted", do: to_string(quote_activity.id))} + else + %{state: state, quoted_status: if(state == "accepted", do: quote_post)} + end + end + + defp build_quote(_object, _quote_activity, _quote_post, _opts), do: nil + def get_quote(activity, %{quoted_activities: quoted_activities}) do object = Object.normalize(activity, fetch: false) diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index f9376b508..82a070767 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -41,6 +41,7 @@ defmodule Pleroma.Web.MediaProxy do end def url(url) when is_nil(url) or url == "", do: nil + def url("//" <> _ = url), do: url("https:" <> url) def url("/" <> _ = url), do: url def url(url) do @@ -56,7 +57,10 @@ defmodule Pleroma.Web.MediaProxy do not local?(url) and not whitelisted?(url) end - def preview_url(url, preview_params \\ []) do + def preview_url(url, preview_params \\ []) + def preview_url("//" <> _ = url, pparams), do: preview_url("https:" <> url, pparams) + + def preview_url(url, preview_params) do if preview_enabled?() do encode_preview_url(url, preview_params) else diff --git a/lib/pleroma/web/o_auth/token_controller.ex b/lib/pleroma/web/o_auth/token_controller.ex index d2313a9cc..296b60510 100644 --- a/lib/pleroma/web/o_auth/token_controller.ex +++ b/lib/pleroma/web/o_auth/token_controller.ex @@ -13,7 +13,8 @@ defmodule Pleroma.Web.OAuth.TokenController do require Logger plug(:skip_auth when action == :confirm_email) - plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) + plug(OAuthScopesPlug, %{scopes: ["read:security"]} when action == :oauth_tokens) + plug(OAuthScopesPlug, %{scopes: ["write:security"]} when action == :revoke_token) action_fallback(:errors) diff --git a/lib/pleroma/web/pleroma_api/controllers/password_controller.ex b/lib/pleroma/web/pleroma_api/controllers/password_controller.ex deleted file mode 100644 index 444477245..000000000 --- a/lib/pleroma/web/pleroma_api/controllers/password_controller.ex +++ /dev/null @@ -1,52 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.PasswordController do - @moduledoc """ - The module contains functions for password reset. - """ - - use Pleroma.Web, :controller - - require Logger - - import Pleroma.Web.ControllerHelper, only: [json_response: 3] - - alias Pleroma.PasswordResetToken - alias Pleroma.Repo - alias Pleroma.User - alias Pleroma.Web.Registration - - plug(Pleroma.Web.Plugs.RateLimiter, [name: :request] when action == :request) - - @doc "POST /auth/password" - def request(conn, params) do - nickname_or_email = params["email"] || params["nickname"] - - Registration.password_reset(nickname_or_email) - - json_response(conn, :no_content, "") - end - - def reset(conn, %{"token" => token}) do - with %{used: false} = token <- Repo.get_by(PasswordResetToken, %{token: token}), - false <- PasswordResetToken.expired?(token), - %User{} = user <- User.get_cached_by_id(token.user_id) do - render(conn, "reset.html", %{ - token: token, - user: user - }) - else - _e -> render(conn, "invalid_token.html") - end - end - - def do_reset(conn, %{"data" => data}) do - with {:ok, _} <- PasswordResetToken.reset_password(data["token"], data) do - render(conn, "reset_success.html") - else - _e -> render(conn, "reset_failed.html") - end - end -end diff --git a/lib/pleroma/web/pleroma_api/controllers/token_controller.ex b/lib/pleroma/web/pleroma_api/controllers/token_controller.ex deleted file mode 100644 index 10eeb907e..000000000 --- a/lib/pleroma/web/pleroma_api/controllers/token_controller.ex +++ /dev/null @@ -1,59 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.TokenController do - use Pleroma.Web, :controller - - alias Pleroma.User - alias Pleroma.Web.OAuth.Token - alias Pleroma.Web.PleromaAPI.TokenView - alias Pleroma.Web.Plugs.OAuthScopesPlug - - require Logger - - plug(:skip_auth when action == :confirm_email) - plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) - - action_fallback(:errors) - - def confirm_email(conn, %{"user_id" => uid, "token" => token}) do - with %User{} = user <- User.get_cached_by_id(uid), - true <- user.local and !user.is_confirmed and user.confirmation_token == token, - {:ok, _} <- User.confirm(user) do - redirect(conn, to: "/") - end - end - - def oauth_tokens(%{assigns: %{user: user}} = conn, _params) do - with oauth_tokens <- Token.get_user_tokens(user) do - conn - |> put_view(TokenView) - |> render("index.json", %{tokens: oauth_tokens}) - end - end - - def revoke_token(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do - Token.delete_user_token(user, id) - - json_reply(conn, 201, "") - end - - defp errors(conn, {:param_cast, _}) do - conn - |> put_status(400) - |> json("Invalid parameters") - end - - defp errors(conn, _) do - conn - |> put_status(500) - |> json("Something went wrong") - end - - defp json_reply(conn, status, json) do - conn - |> put_resp_content_type("application/json") - |> send_resp(status, json) - end -end diff --git a/lib/pleroma/web/plugs/http_signature_plug.ex b/lib/pleroma/web/plugs/http_signature_plug.ex index 2e16212ce..1f7cb51f3 100644 --- a/lib/pleroma/web/plugs/http_signature_plug.ex +++ b/lib/pleroma/web/plugs/http_signature_plug.ex @@ -101,7 +101,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do conn.assigns[:actor_id] do %{host: host} = URI.parse(conn.assigns.actor_id) - if MRF.subdomain_match?(rejected_domains(), host) do + if MRF.subdomain_match?(rejected_domains(), host) or not MRF.instance_allowed?(host) do conn |> put_status(:unauthorized) |> halt() diff --git a/lib/pleroma/workers/publisher_worker.ex b/lib/pleroma/workers/publisher_worker.ex index 186f6f4ef..7bd00aad8 100644 --- a/lib/pleroma/workers/publisher_worker.ex +++ b/lib/pleroma/workers/publisher_worker.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Workers.PublisherWorker do alias Pleroma.Activity alias Pleroma.Config alias Pleroma.Instances + alias Pleroma.Web.ActivityPub.Publisher + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator use Oban.Worker, queue: :federator_outgoing, max_attempts: 13 @@ -32,26 +34,39 @@ defmodule Pleroma.Workers.PublisherWorker do end end) - # Cancel / skip the job if this server believed to be unreachable now - if not Instances.reachable?(params.inbox) do - {:cancel, :unreachable} - else - case federator().perform(:publish_one, params) do - {:ok, _} -> - :ok + cond do + # Cancel / skip the job if this server believed to be unreachable now + not Instances.reachable?(params.inbox) -> + {:cancel, :unreachable} - {:error, _} = error -> - # Only mark as unreachable on final failure - if job.attempt == job.max_attempts do - Instances.set_unreachable(params.inbox) - end + # The instance may have been blocked after the job was queued + not federate?(params) -> + {:cancel, :rejected} - error + true -> + case federator().perform(:publish_one, params) do + {:ok, _} -> + :ok - error -> - # Unexpected error, may have been client side - error - end + {:error, _} = error -> + # Only mark as unreachable on final failure + if job.attempt == job.max_attempts do + Instances.set_unreachable(params.inbox) + end + + error + + error -> + # Unexpected error, may have been client side + error + end + end + end + + defp federate?(%{inbox: inbox, activity_id: activity_id}) do + case Activity.get_by_id(activity_id) do + %Activity{} = activity -> Publisher.should_federate?(inbox, Visibility.public?(activity)) + nil -> true end end diff --git a/mix.exs b/mix.exs index d6c2be21f..e2af5fda6 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.11.0"), + version: version("2.11.1"), elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), compilers: Mix.compilers(), @@ -224,9 +224,7 @@ defmodule Pleroma.Mixfile do {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.9", only: :test}, {:covertool, "~> 2.0", only: :test}, - {:hackney, "~> 4.7.3", override: true}, - # Hackney 4.7.3 pins certifi ~> 2.17.0; 2.18.0 drops the distrusted emSign roots. - {:certifi, "~> 2.18.0", override: true}, + {:hackney, "~> 4.9.1", override: true}, {:mox, "~> 1.2", only: :test}, {:websockex, "~> 0.4.3", only: :test}, {:benchee, "~> 1.4", only: :benchmark}, diff --git a/mix.lock b/mix.lock index 2f2cce9e2..f8ce53f4c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, "argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"}, - "bandit": {:hex, :bandit, "1.13.1", "1ce25a7beb750ed3fa2e905962f1f9a679cb585f1da0ae45637d06e385ce2eae", [:mix], [{:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.6", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "39028f0b5a21404661410a6c19275060a9822c9dcca70b7c3e4e4fa50b11da58"}, + "bandit": {:hex, :bandit, "1.14.0", "555af34f126edb98d8b6b95bbeba4d5f4c3af21e37535d791f32db2365c532fb", [:mix], [{:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.6", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "2d07630e4bf77b55240881386f7629bcc2f87426a299860b2d31c4ad77a48746"}, "base62": {:hex, :base62, "1.2.2", "85c6627eb609317b70f555294045895ffaaeb1758666ab9ef9ca38865b11e629", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "d41336bda8eaa5be197f1e4592400513ee60518e5b9f4dcf38f4b4dae6f377bb"}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.1", "5114d780459a04f2b4aeef52307de23de961b69e13a5cd98a911e39fda13f420", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "42182d5f46764def15bf9af83739e3bf4ad22661b1c34fc3e88558efced07279"}, @@ -58,8 +58,8 @@ "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, "gun": {:hex, :gun, "2.7.0", "8dfc20ff1b70f3c78f07122f7d82ac6950c40d33a8a9b35da0134f49fb35b334", [:make, :rebar3], [{:cowlib, ">= 2.21.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "2a90d073069e72654b6c9a91fae365b746ff5ce385a4a5bbf71ef53a8bf95d82"}, - "h2": {:hex, :h2, "0.11.0", "b8c9f7c86f400582c5cbed59f79c0efd6cf49ad6478c210bede91d2d15019b14", [:rebar3], [], "hexpm", "c61f24361d0dc76375eaf35c2bf0e01235d81b7685538df571db036467fac358"}, - "hackney": {:hex, :hackney, "4.7.3", "42feec1a4986000411cb5d389bbc9fb61d9dff84c5eef42b11fc40ea327e6f9a", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.11.0", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.4", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "3f460e01d5a1abba39c32d06e9a733bb76e35d1ab87a83868b6bb29d646fe25f"}, + "h2": {:hex, :h2, "0.12.5", "03d492b4cce77466c4c4a2b3fd74b2bb16c1d78f9618bbfeb50ea493c005accf", [:rebar3], [], "hexpm", "a4524bf80cafb86e599710c1d5c9f63aca0284607cb4d99fb53ae63fd55aa03f"}, + "hackney": {:hex, :hackney, "4.9.1", "1c1c3d75ae0f9c35df40f56ca63ba2f8c9986d988d67ae8ebe26f96697e81485", [:rebar3], [{:certifi, "~> 2.18.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.5", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.1.2", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.8", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "093e1830e523c2b1972d570dcee83d000d41f7b39e7eb3c6b5240cf0f64a1973"}, "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "http_signatures": {:hex, :http_signatures, "0.1.3", "19f26aee35b4684e37efdce3ac4638605e6e41a04368186bd39d2b6138a60ea9", [:mix], [{:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20313a65516db88006f85b090f6f76cc5b04e9609b45943657e6781eb91174f4"}, @@ -130,7 +130,7 @@ "prometheus_phx": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/prometheus-phx.git", "9cd8f248c9381ffedc799905050abce194a97514", [branch: "no-logging"]}, "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm", "0273a6483ccb936d79ca19b0ab629aef0dba958697c94782bb728b920dfc6a79"}, "quantile_estimator": {:hex, :quantile_estimator, "0.2.1", "ef50a361f11b5f26b5f16d0696e46a9e4661756492c981f7b2229ef42ff1cd15", [:rebar3], [], "hexpm", "282a8a323ca2a845c9e6f787d166348f776c1d4a41ede63046d72d422e3da946"}, - "quic": {:hex, :quic, "1.8.0", "7b074fc6f8a13b4a81e4e11254bf061db61c5c07b4366d568a79a38c5e8bb5f3", [:rebar3], [], "hexpm", "6b1f8f08a45412ac503bc6771efb8edc696bb14079bc48d2ca960b92933da8a4"}, + "quic": {:hex, :quic, "2.1.3", "c66861354d39b0f2b8ac0d7cde56d90db0168ac7b3ffa11d1cb8ab37237d1233", [:rebar3], [], "hexpm", "1520df50fc63093c0a4d209cb97056e132d06f0b8fd54c5cf933ddc0c0ea6fe2"}, "ranch": {:hex, :ranch, "2.2.1", "fc2bb0e800efbeab8ff82eb325450b9c08653b14c3f69e8b09bbea304973105d", [:make, :rebar3], [], "hexpm", "55f05cce20ec2da1d90de5d5981afb93dbfc01325fc7e933189aa5c62f037c24"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, @@ -158,7 +158,7 @@ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, - "webtransport": {:hex, :webtransport, "0.4.4", "714180b340fbd30a7d666f3f593a612186ff1ba444566b3e8c9ce37fb2950638", [:rebar3], [{:h2, "~> 0.11.0", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 1.8.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "80a6641e0f6628938ceee4c9d7a4f6d48a6089890da1ae3288ca8dab13a6005e"}, + "webtransport": {:hex, :webtransport, "0.4.8", "3c340d7ea381e3f639ece90378d85fb46b1da4eece17bba3223051e833166501", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "08477a2195ccbf944da0b78f85dc4e6ee4498790e3dd7fe84ee7a245938e1b26"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, } diff --git a/priv/gettext/en/LC_MESSAGES/oauth_scopes.po b/priv/gettext/en/LC_MESSAGES/oauth_scopes.po index 105ca022b..48654666f 100644 --- a/priv/gettext/en/LC_MESSAGES/oauth_scopes.po +++ b/priv/gettext/en/LC_MESSAGES/oauth_scopes.po @@ -33,6 +33,11 @@ msgstr "Write all using admin API" msgid "follow" msgstr "Read and write user relationships" +#, elixir-autogen, elixir-format +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +msgid "profile" +msgstr "Read only your public profile, to log in to other services" + #, elixir-autogen, elixir-format #: lib/pleroma/web/api_spec/scopes/translator.ex:5 msgid "push" diff --git a/priv/gettext/oauth_scopes.pot b/priv/gettext/oauth_scopes.pot index 83328770e..57033c8d2 100644 --- a/priv/gettext/oauth_scopes.pot +++ b/priv/gettext/oauth_scopes.pot @@ -220,6 +220,11 @@ msgstr "" msgid "read:mutes" msgstr "" +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "profile" +msgstr "" + #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "push" diff --git a/priv/scrubbers/media_proxy.ex b/priv/scrubbers/media_proxy.ex index 9935b3829..8a73e8d9b 100644 --- a/priv/scrubbers/media_proxy.ex +++ b/priv/scrubbers/media_proxy.ex @@ -17,6 +17,10 @@ defmodule Pleroma.HTML.Transform.MediaProxy do {"src", media_url} end + def scrub_attribute(:img, {"src", "//" <> target}) do + scrub_attribute(:img, {"src", "https://" <> target}) + end + def scrub_attribute(_tag, attribute), do: attribute def scrub({:img, attributes, children}) do diff --git a/test/fixtures/bridgy/actor.json b/test/fixtures/bridgy/actor.json index 5b2d8982b..b4e859a82 100644 --- a/test/fixtures/bridgy/actor.json +++ b/test/fixtures/bridgy/actor.json @@ -70,7 +70,7 @@ "preferredUsername": "jk.nipponalba.scot", "summary": "", "publicKey": { - "id": "jk.nipponalba.scot", + "id": "https://fed.brid.gy/jk.nipponalba.scot#key", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdarxwzxnNbJ2hneWOYHkYJowk\npyigQtxlUd0VjgSQHwxU9kWqfbrHBVADyTtcqi/4dAzQd3UnCI1TPNnn4LPZY9PW\noiWd3Zl1/EfLFxO7LU9GS7fcSLQkyj5JNhSlN3I8QPudZbybrgRDVZYooDe1D+52\n5KLGqC2ajrIVOiDRTQIDAQAB\n-----END PUBLIC KEY-----" }, "inbox": "https://fed.brid.gy/jk.nipponalba.scot/inbox", diff --git a/test/fixtures/tesla_mock/relay@mastdon.example.org.json b/test/fixtures/tesla_mock/relay@mastdon.example.org.json index c1fab7d3b..21dd405c8 100644 --- a/test/fixtures/tesla_mock/relay@mastdon.example.org.json +++ b/test/fixtures/tesla_mock/relay@mastdon.example.org.json @@ -11,7 +11,7 @@ "toot": "http://joinmastodon.org/ns#", "Emoji": "toot:Emoji" }], - "id": "http://mastodon.example.org/users/admin", + "id": "http://mastodon.example.org/users/relay", "type": "Application", "invisible": true, "following": "http://mastodon.example.org/users/admin/following", @@ -24,8 +24,8 @@ "url": "http://mastodon.example.org/@admin", "manuallyApprovesFollowers": false, "publicKey": { - "id": "http://mastodon.example.org/users/admin#main-key", - "owner": "http://mastodon.example.org/users/admin", + "id": "http://mastodon.example.org/users/relay#main-key", + "owner": "http://mastodon.example.org/users/relay", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtc4Tir+3ADhSNF6VKrtW\nOU32T01w7V0yshmQei38YyiVwVvFu8XOP6ACchkdxbJ+C9mZud8qWaRJKVbFTMUG\nNX4+6Q+FobyuKrwN7CEwhDALZtaN2IPbaPd6uG1B7QhWorrY+yFa8f2TBM3BxnUy\nI4T+bMIZIEYG7KtljCBoQXuTQmGtuffO0UwJksidg2ffCF5Q+K//JfQagJ3UzrR+\nZXbKMJdAw4bCVJYs4Z5EhHYBwQWiXCyMGTd7BGlmMkY6Av7ZqHKC/owp3/0EWDNz\nNqF09Wcpr3y3e8nA10X40MJqp/wR+1xtxp+YGbq/Cj5hZGBG7etFOmIpVBrDOhry\nBwIDAQAB\n-----END PUBLIC KEY-----\n" }, "attachment": [{ diff --git a/test/pleroma/filter_test.exs b/test/pleroma/filter_test.exs index 6d33cd22b..2fc72d9f5 100644 --- a/test/pleroma/filter_test.exs +++ b/test/pleroma/filter_test.exs @@ -190,4 +190,66 @@ defmodule Pleroma.FilterTest do {:error, _} = Filter.update(filter, changes) end + + describe "compose_regex/2" do + defp filters(phrases), do: Enum.map(phrases, &%Filter{phrase: &1}) + + defp pg_match?(regex, text) do + %{rows: [[match]]} = Repo.query!("SELECT $1::text ~* $2", [text, regex]) + match + end + + test "treats phrases as literal text in Postgres" do + regex = Filter.compose_regex(filters(["a.b", "(c"])) + + assert pg_match?(regex, "see a.b here") + refute pg_match?(regex, "see axb here") + assert pg_match?(regex, "see (c here") + end + + test "treats phrases as literal text in Elixir" do + regex = Filter.compose_regex(filters(["a.b", "(c"]), :re) + + assert Regex.match?(regex, "see a.b here") + refute Regex.match?(regex, "see axb here") + assert Regex.match?(regex, "see (c here") + end + + test "keeps word boundaries for every phrase in Postgres" do + regex = Filter.compose_regex(filters(["cat", "dog"])) + + assert pg_match?(regex, "a dog barks") + refute pg_match?(regex, "a catalog") + refute pg_match?(regex, "a hotdog") + end + + test "only adds word boundaries next to word characters" do + pg = Filter.compose_regex(filters(["#tag"])) + re = Filter.compose_regex(filters(["#tag"]), :re) + + for {text, expected} <- [{"a #tag here", true}, {"a#tag", true}, {"some #tags", false}] do + assert pg_match?(pg, text) == expected + assert Regex.match?(re, text) == expected + end + end + + test "matches inside words when whole_word is false" do + filters = [%Filter{phrase: "cat", whole_word: false}, %Filter{phrase: "dog"}] + pg = Filter.compose_regex(filters) + re = Filter.compose_regex(filters, :re) + + for {text, expected} <- [{"a catalog", true}, {"a hotdog", false}, {"a dog", true}] do + assert pg_match?(pg, text) == expected + assert Regex.match?(re, text) == expected + end + end + + test "keeps word boundaries for every phrase in Elixir" do + regex = Filter.compose_regex(filters(["cat", "dog"]), :re) + + assert Regex.match?(regex, "a dog barks") + refute Regex.match?(regex, "a catalog") + refute Regex.match?(regex, "a hotdog") + end + end end diff --git a/test/pleroma/http/hackney_conn_leak_test.exs b/test/pleroma/http/hackney_conn_leak_test.exs index f83cad73d..424385dac 100644 --- a/test/pleroma/http/hackney_conn_leak_test.exs +++ b/test/pleroma/http/hackney_conn_leak_test.exs @@ -14,15 +14,15 @@ # # Pleroma cannot work around this: since 4.0 Hackney ignores `with_body` and # always returns a fully read binary, so a synchronous request never yields a -# handle to close, and a truncated read still reports `{:ok, body}`. +# handle to close. Up to 4.8.2 a truncated read also reported `{:ok, body}`; +# since 4.8.3 it is `{:error, {:closed, partial_body}}`. # # Media proxy warming hits this whenever an attachment fetch is cut short and # the outbound proxy is used as a CONNECT tunnel, since tunnels are never # pooled. That is how it was found. # -# Fixed upstream by https://github.com/benoitc/hackney/pull/919; mix.exs pins -# that commit until it lands in a release. Should this start failing, the pin -# has been moved to a version without the fix. +# Fixed upstream by https://github.com/benoitc/hackney/pull/919. Should this +# start failing, mix.exs has moved to a Hackney version without the fix. # # https://github.com/benoitc/hackney/issues/918 defmodule Pleroma.HTTP.HackneyConnLeakTest do @@ -50,11 +50,9 @@ defmodule Pleroma.HTTP.HackneyConnLeakTest do # pool: false takes hackney's connect_direct/4 path, the same unpooled shape # a CONNECT tunnel gets. A pooled connection is reaped by the pool's own # grace timer and would not show the leak. - assert {:ok, %Tesla.Env{status: 200, body: body}} = - request(server, pool: false) + assert {:error, {:closed, body}} = request(server, pool: false) - # The short read still reports success, which is why nothing downstream - # knows to clean up. + # The short read hands back what did arrive. assert byte_size(body) == @sent_bytes leaked = wait_for_cleanup(before) @@ -70,7 +68,7 @@ defmodule Pleroma.HTTP.HackneyConnLeakTest do test "a pooled request with a truncated body does not leak either", %{server: server} do before = conn_pids() - assert {:ok, %Tesla.Env{status: 200}} = request(server, pool: :media) + assert {:error, {:closed, _}} = request(server, pool: :media) assert wait_for_cleanup(before) == [] end diff --git a/test/pleroma/http/hackney_max_body_regression_test.exs b/test/pleroma/http/hackney_max_body_regression_test.exs index 26de0e021..27fe2cddb 100644 --- a/test/pleroma/http/hackney_max_body_regression_test.exs +++ b/test/pleroma/http/hackney_max_body_regression_test.exs @@ -4,8 +4,9 @@ # Hackney 4 ignores `with_body` and always returns a fully read binary, so # `Tesla.Adapter.Hackney` never reaches the clause that honours `:max_body`. -# The limit is enforced by Pleroma.Tesla.Middleware.MaxBody instead; these -# exercise it against the real adapter. +# Hackney 4.9 enforces `max_body` itself while reading (CVE-2026-97200); +# Pleroma.Tesla.Middleware.MaxBody stays as the check for every adapter. These +# exercise both against the real adapter. defmodule Pleroma.HTTP.HackneyMaxBodyRegressionTest do use ExUnit.Case, async: false @@ -43,8 +44,8 @@ defmodule Pleroma.HTTP.HackneyMaxBodyRegressionTest do request("#{server.base_url}/big", max_body: 1024) end - # Without a Content-Length the header pre-check cannot fire, so this is the - # case that proves hackney's fully-read binary is measured. + # Without a Content-Length the header pre-check cannot fire, so only a size + # check on the body can refuse this. test "a chunked body over max_body is refused on size alone", %{server: server} do assert {:error, :body_too_large} = request("#{server.base_url}/chunked", max_body: 1024) @@ -57,17 +58,16 @@ defmodule Pleroma.HTTP.HackneyMaxBodyRegressionTest do assert byte_size(body) == @body_bytes end - test "without max_body the body is returned whatever its size", %{server: server} do + test "without max_body the body is returned", %{server: server} do assert {:ok, %Tesla.Env{status: 200, body: body}} = request("#{server.base_url}/big", []) assert byte_size(body) == @body_bytes end - # Pins why the middleware is needed. Tesla.Adapter.Hackney only honours - # :max_body on the branch where hackney hands back a ref, which 4.x never - # does for a synchronous request. Should this ever start failing, hackney or - # Tesla have regained streaming and the middleware can go. - test "the bare adapter does not enforce max_body", %{server: server} do - url = "#{server.base_url}/big" + # Hackney 4.0 to 4.8 read the whole body whatever `max_body` said; 4.9 + # stops reading once the body goes over it (CVE-2026-97200). Should this + # start failing, the hackney pin has gone back to a version without the fix. + test "the bare adapter enforces max_body", %{server: server} do + url = "#{server.base_url}/chunked" adapter_opts = HackneyAdapterHelper.options( @@ -77,10 +77,8 @@ defmodule Pleroma.HTTP.HackneyMaxBodyRegressionTest do client = Tesla.client([], Tesla.Adapter.Hackney) - assert {:ok, %Tesla.Env{status: 200, body: body}} = + assert {:error, :body_too_large} = Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts]) - - assert byte_size(body) == @body_bytes end defp start_server do diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index e673e6591..436a22447 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -29,6 +29,10 @@ defmodule Pleroma.HTTPTest do %{method: :post, url: "http://example.com/world"} -> %Tesla.Env{status: 200, body: "world"} + # Hackney 4.9 hands back the bytes it read before the peer closed. + %{method: :get, url: "http://example.com/truncated"} -> + {:error, {:closed, "partial body"}} + %{method: :get, url: "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"} -> %Tesla.Env{status: 200, body: "emoji data"} @@ -81,6 +85,12 @@ defmodule Pleroma.HTTPTest do end end + describe "get/1 with a truncated body" do + test "drops the partial body from the error" do + assert HTTP.get("http://example.com/truncated") == {:error, :closed} + end + end + describe "get/2 (with headers)" do test "returns successfully result for json content-type" do assert HTTP.get("http://example.com/hello", [{"content-type", "application/json"}]) == diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 992b15f8b..091b9affc 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -7,8 +7,11 @@ defmodule Pleroma.NotificationTest do import Pleroma.Factory + require Pleroma.Constants + alias Pleroma.FollowingRelationship alias Pleroma.Notification + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User @@ -276,6 +279,22 @@ defmodule Pleroma.NotificationTest do assert {:ok, []} == Notification.create_notifications(status) end + test "it creates notifications for objects without content when the user has filters" do + author = insert(:user) + user = insert(:user) + insert(:filter, user: user, phrase: "cofe", hide: true) + + {:ok, status} = CommonAPI.post(author, %{status: "hey @#{user.nickname}"}) + object = Object.normalize(status, fetch: false) + + {:ok, _} = + object + |> Object.change(%{data: Map.delete(object.data, "content")}) + |> Object.update_and_set_cache() + + assert {:ok, [_]} = Notification.create_notifications(Pleroma.Activity.get_by_id(status.id)) + end + test "it creates notifications if content matches with a not irreversible filter" do user = insert(:user) subscriber = insert(:user) @@ -611,6 +630,102 @@ defmodule Pleroma.NotificationTest do assert other_user in enabled_receivers end + defp incoming_note_tagging(author, to, tagged_ap_ids) do + tags = Enum.map(tagged_ap_ids, &%{"type" => "Mention", "href" => &1, "name" => &1}) + + {:ok, activity} = + Transmogrifier.handle_incoming(%{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Create", + "to" => to, + "actor" => author.ap_id, + "object" => %{ + "type" => "Note", + "id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(), + "to" => to, + "content" => "tagged", + "tag" => tags, + "attributedTo" => author.ap_id + } + }) + + activity + end + + defp notified_ap_ids(activity), + do: activity |> Notification.get_notified_from_activity() |> Enum.map(& &1.ap_id) + + test "it does not notify tagged users who cannot see a followers-only post" do + author = insert(:user) + tagged = insert(:user) + + activity = incoming_note_tagging(author, [author.follower_address], [tagged.ap_id]) + + refute tagged.ap_id in notified_ap_ids(activity) + end + + test "it notifies tagged followers of a followers-only post" do + author = insert(:user) + tagged = insert(:user) + {:ok, _, _} = Pleroma.User.follow(tagged, author) + + activity = incoming_note_tagging(author, [author.follower_address], [tagged.ap_id]) + + assert tagged.ap_id in notified_ap_ids(activity) + end + + test "it does not notify tagged users who are not addressed by a direct message" do + author = insert(:user) + addressed = insert(:user) + tagged = insert(:user) + + activity = incoming_note_tagging(author, [addressed.ap_id], [tagged.ap_id]) + receivers = notified_ap_ids(activity) + + assert addressed.ap_id in receivers + refute tagged.ap_id in receivers + end + + test "it notifies tagged users of a public post even if they are not addressed" do + author = insert(:user) + tagged = insert(:user) + + activity = + incoming_note_tagging(author, [Pleroma.Constants.as_public()], [tagged.ap_id]) + + assert tagged.ap_id in notified_ap_ids(activity) + end + + test "it does not notify tagged users with a pending follow request" do + author = insert(:user, is_locked: true) + tagged = insert(:user) + {:ok, _, _, _} = CommonAPI.follow(author, tagged) + + activity = incoming_note_tagging(author, [author.follower_address], [tagged.ap_id]) + + refute tagged.ap_id in notified_ap_ids(activity) + end + + test "it ignores tags of unknown users in a followers-only post" do + author = insert(:user) + tagged = insert(:user) + + follower = insert(:user) + {:ok, _, _} = Pleroma.User.follow(follower, author) + + activity = + incoming_note_tagging(author, [author.follower_address], [ + "https://unknown.example/users/nobody", + tagged.ap_id, + follower.ap_id + ]) + + receivers = notified_ap_ids(activity) + + refute tagged.ap_id in receivers + assert follower.ap_id in receivers + end + test "it does not send notifications to users who are only cc in new messages" do user = insert(:user) other_user = insert(:user) diff --git a/test/pleroma/object/containment_test.exs b/test/pleroma/object/containment_test.exs index 5560dfce5..2fdc83605 100644 --- a/test/pleroma/object/containment_test.exs +++ b/test/pleroma/object/containment_test.exs @@ -127,4 +127,56 @@ defmodule Pleroma.Object.ContainmentTest do :ok = Containment.contain_child(data) end end + + describe "get_actor/1" do + test "returns nil for actor shapes without an id" do + for actor <- [%{"type" => "Person"}, %{"id" => 1}, 1, true] do + assert nil == Containment.get_actor(%{"actor" => actor}) + assert nil == Containment.get_actor(%{"actor" => nil, "attributedTo" => actor}) + end + + assert nil == Containment.get_actor(%{}) + end + + test "contain_origin/2 rejects actor shapes without an id" do + assert :error == + Containment.contain_origin("https://a.test/o/1", %{ + "attributedTo" => %{"type" => "Person"} + }) + end + end + + describe "contain_id_to_fetch/2" do + test "matches the exact id" do + assert :ok = + Containment.contain_id_to_fetch("https://a.test/o/1", %{ + "id" => "https://a.test/o/1" + }) + end + + test "ignores fragments, a trailing slash and the host case" do + assert :ok = + Containment.contain_id_to_fetch("https://A.test/o/1/", %{ + "id" => "https://a.test/o/1#x" + }) + end + + test "rejects another id on the same host" do + assert :error = + Containment.contain_id_to_fetch("https://a.test/o/1", %{ + "id" => "https://a.test/o/2" + }) + end + + test "rejects a different query" do + assert :error = + Containment.contain_id_to_fetch("https://a.test/o?page=1", %{ + "id" => "https://a.test/o?page=2" + }) + end + + test "rejects documents without an id" do + assert :error = Containment.contain_id_to_fetch("https://a.test/o/1", %{}) + end + end end diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index 9afa34fa2..61a6fb517 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -48,6 +48,8 @@ defmodule Pleroma.Object.FetcherTest do %{method: :get, url: "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM"} -> %Tesla.Env{ status: 200, + # Pleroma redirects /notice/:id to the object id + url: "https://social.sakamoto.gq/objects/f20f2497-66d9-4a52-a2e1-1be2a39c32c1", body: File.read!("test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json"), headers: HttpRequestMock.activitypub_object_headers() } @@ -463,6 +465,78 @@ defmodule Pleroma.Object.FetcherTest do %{object1: local_object1, object2: local_object2} end + test "it rejects a refetched object whose actor changed", %{object1: object1} do + insert(:user, ap_id: "https://mastodon.social/users/someone_else") + {:ok, o} = Object.create(object1) + + mock(fn %{method: :get, url: "https://mastodon.social/1"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + object1 + |> Map.merge(%{ + "actor" => "https://mastodon.social/users/someone_else", + "attributedTo" => "https://mastodon.social/users/someone_else", + "content" => "hijacked" + }) + |> Jason.encode!() + } + end) + + assert {:error, _} = Fetcher.refetch_object(o) + assert Object.get_by_ap_id("https://mastodon.social/1").data["content"] == "test 1" + end + + test "it rejects a refetched object whose author has no id", %{object1: object1} do + {:ok, o} = Object.create(object1) + + mock(fn %{method: :get, url: "https://mastodon.social/1"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + object1 + |> Map.delete("actor") + |> Map.put("attributedTo", %{"type" => "Person"}) + |> Jason.encode!() + } + end) + + assert {:error, _} = Fetcher.refetch_object(o) + end + + test "it rejects a refetched object whose id changed", %{object1: object1} do + {:ok, o} = Object.create(object1) + + mock(fn %{method: :get, url: "https://mastodon.social/1"} -> + %Tesla.Env{ + status: 200, + url: "https://mastodon.social/2", + headers: [{"content-type", "application/activity+json"}], + body: object1 |> Map.put("id", "https://mastodon.social/2") |> Jason.encode!() + } + end) + + assert {:error, _} = Fetcher.refetch_object(o) + end + + test "it rejects a refetched object whose id differs only by a trailing slash", %{ + object1: object1 + } do + {:ok, o} = Object.create(object1) + + mock(fn %{method: :get, url: "https://mastodon.social/1"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: object1 |> Map.put("id", "https://mastodon.social/1/") |> Jason.encode!() + } + end) + + assert {:error, _} = Fetcher.refetch_object(o) + end + test "it keeps formerRepresentations if remote does not have this attr", %{object1: object1} do full_object1 = object1 @@ -642,7 +716,23 @@ defmodule Pleroma.Object.FetcherTest do }) } - # No redirect - same domain + # Same-domain redirect to the object id + %{method: :get, url: "https://original.test/@actor/redirect"} -> + %Tesla.Env{ + status: 200, + url: "https://original.test/objects/redirect-target", + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "id" => "https://original.test/objects/redirect-target", + "type" => "Note", + "content" => "This has a same-domain redirect", + "actor" => "https://original.test/users/actor", + "attributedTo" => "https://original.test/users/actor" + }) + } + + # Same-domain redirect to a document claiming the original URL as id %{method: :get, url: "https://original.test/objects/same-domain-redirect"} -> %Tesla.Env{ status: 200, @@ -695,12 +785,19 @@ defmodule Pleroma.Object.FetcherTest do test "it accepts objects with same-domain redirects" do assert {:ok, data} = Fetcher.fetch_and_contain_remote_object_from_id( - "https://original.test/objects/same-domain-redirect" + "https://original.test/@actor/redirect" ) assert data["content"] == "This has a same-domain redirect" end + test "it rejects redirect targets that claim the original URL as id" do + assert {:error, _} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://original.test/objects/same-domain-redirect" + ) + end + test "it handles responses without URL field (common in tests)" do assert {:ok, data} = Fetcher.fetch_and_contain_remote_object_from_id( @@ -711,6 +808,150 @@ defmodule Pleroma.Object.FetcherTest do end end + describe "id containment" do + setup do + note = fn id, content -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "id" => id, + "type" => "Note", + "content" => content, + "actor" => "https://host.test/users/actor", + "attributedTo" => "https://host.test/users/actor" + }) + } + end + + mock(fn + # display URL serving the canonical object + %{method: :get, url: "https://host.test/@actor/1"} -> + note.("https://host.test/objects/1", "display") + + %{method: :get, url: "https://host.test/objects/1"} -> + note.("https://host.test/objects/1", "canonical") + + # uploaded file claiming to be another object on the same host + %{method: :get, url: "https://host.test/media/evil.json"} -> + note.("https://host.test/objects/2", "spoofed") + + %{method: :get, url: "https://host.test/objects/2"} -> + note.("https://host.test/objects/2", "real") + + # canonical id does not serve itself + %{method: :get, url: "https://host.test/media/loop.json"} -> + note.("https://host.test/objects/3", "loop") + + %{method: :get, url: "https://host.test/objects/3"} -> + note.("https://host.test/objects/4", "other") + + # canonical id redirects + %{method: :get, url: "https://host.test/media/redirect.json"} -> + note.("https://host.test/objects/5", "redirect") + + %{method: :get, url: "https://host.test/objects/5"} -> + %{note.("https://host.test/objects/5", "moved") | url: "https://host.test/media/x"} + + %{method: :get, url: "https://host.test/objects/slash"} -> + note.("https://host.test/objects/slash/", "slash") + + %{method: :get, url: "https://host.test/objects/no-id"} -> + note.(nil, "no id") + end) + + :ok + end + + test "it refetches display URLs from the canonical id" do + assert {:ok, %{"id" => "https://host.test/objects/1", "content" => "canonical"}} = + Fetcher.fetch_and_contain_remote_object_from_id("https://host.test/@actor/1") + end + + test "it does not trust a document claiming another id on the same host" do + assert {:ok, %{"id" => "https://host.test/objects/2", "content" => "real"}} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://host.test/media/evil.json" + ) + end + + test "it refetches at most once" do + assert {:error, :id_mismatch} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://host.test/media/loop.json" + ) + end + + test "it does not follow redirects when refetching from the canonical id" do + assert {:error, _} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://host.test/media/redirect.json" + ) + end + + test "it ignores a trailing slash" do + assert {:ok, %{"content" => "slash"}} = + Fetcher.fetch_and_contain_remote_object_from_id("https://host.test/objects/slash") + end + + test "it accepts ids that only differ by URL encoding" do + clear_config(:test_url_encoding, true) + + mock(fn %{method: :get, url: url} -> + # the encoder sends uppercase escapes + assert url in [ + "https://host.test/users/%E3%81%82", + "https://host.test/notes/%E3%81%82" + ] + + id = + if url =~ "users", + do: "https://host.test/users/あ", + else: "https://host.test/notes/%e3%81%82" + + %Tesla.Env{ + status: 200, + url: url, + headers: [{"content-type", "application/activity+json"}], + body: Jason.encode!(%{"id" => id, "type" => "Note"}) + } + end) + + assert {:ok, _} = + Fetcher.fetch_and_contain_remote_object_from_id("https://host.test/users/あ") + + assert {:ok, _} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://host.test/notes/%e3%81%82" + ) + end + + test "it rejects objects whose author has no id" do + mock(fn %{method: :get, url: "https://host.test/objects/anon" = url} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "id" => url, + "type" => "Note", + "content" => "anon", + "attributedTo" => %{"type" => "Person", "name" => "nobody"} + }) + } + end) + + assert {:containment, :error} = + Fetcher.fetch_object_from_id("https://host.test/objects/anon") + end + + test "it rejects documents without an id" do + assert {:error, _} = + Fetcher.fetch_and_contain_remote_object_from_id("https://host.test/objects/no-id") + end + end + describe "fetch with history" do setup do object2 = %{ diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index ec5a2a12a..32d582b50 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -1197,11 +1197,32 @@ defmodule Pleroma.UserTest do res = User.get_followers(user) + assert length(res) == 2 assert Enum.member?(res, follower_one) assert Enum.member?(res, follower_two) refute Enum.member?(res, not_follower) end + test "does not return deactivated followers" do + user = insert(:user) + follower_one = insert(:user) + follower_two = insert(:user) + not_follower = insert(:user) + + {:ok, follower_one, user} = User.follow(follower_one, user) + {:ok, follower_two, user} = User.follow(follower_two, user) + + User.delete(follower_two) + follower_two = User.get_cached_by_id(follower_two.id) + + res = User.get_followers(user) + + assert length(res) == 1 + assert Enum.member?(res, follower_one) + refute Enum.member?(res, follower_two) + refute Enum.member?(res, not_follower) + end + test "gets all friends (followed users) for a given user" do user = insert(:user) followed_one = insert(:user) @@ -1215,10 +1236,34 @@ defmodule Pleroma.UserTest do followed_one = User.get_cached_by_ap_id(followed_one.ap_id) followed_two = User.get_cached_by_ap_id(followed_two.ap_id) + + assert length(res) == 2 assert Enum.member?(res, followed_one) assert Enum.member?(res, followed_two) refute Enum.member?(res, not_followed) end + + test "does not return deactivated followed users (friends)" do + user = insert(:user) + followed_one = insert(:user) + followed_two = insert(:user) + not_followed = insert(:user) + + {:ok, user, followed_one} = User.follow(user, followed_one) + {:ok, user, followed_two} = User.follow(user, followed_two) + + User.delete(followed_two) + + res = User.get_friends(user) + + followed_one = User.get_cached_by_id(followed_one.id) + followed_two = User.get_cached_by_id(followed_two.id) + + assert length(res) == 1 + assert Enum.member?(res, followed_one) + refute Enum.member?(res, followed_two) + refute Enum.member?(res, not_followed) + end end describe "updating note and follower count" do @@ -2964,6 +3009,18 @@ defmodule Pleroma.UserTest do assert [%{id: ^id2}] = User.get_familiar_followers(user3, user1) end + + test "does not return deactivated users" do + user1 = insert(:user) + user2 = insert(:user) + user3 = insert(:user) + + User.follow(user1, user2) + User.follow(user2, user3) + {:ok, _} = User.set_activation(user2, false) + + assert [] = User.get_familiar_followers(user3, user1) + end end describe "account endorsements" do 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 0de9618d4..13a4ee108 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -3104,4 +3104,125 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do obj_id in object_ids end) end + + describe "OAuth scopes" do + setup do + Mox.stub_with(Pleroma.StaticStubbedConfigMock, Pleroma.Config) + [user: insert(:user)] + end + + defp with_token(conn, user, scopes) do + token = insert(:oauth_token, user: user, scopes: scopes) + + conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> put_req_header("accept", "application/activity+json") + end + + test "GET /api/ap/whoami needs read:accounts", %{conn: conn, user: user} do + assert conn |> with_token(user, ["profile"]) |> get("/api/ap/whoami") |> json_response(403) + + assert %{"id" => id} = + conn + |> with_token(user, ["read:accounts"]) + |> get("/api/ap/whoami") + |> json_response(200) + + assert id == user.ap_id + end + + test "GET /users/:nickname/inbox needs read:statuses", %{conn: conn, user: user} do + inbox = "/users/#{user.nickname}/inbox" + + assert conn |> with_token(user, ["read:accounts"]) |> get(inbox) |> json_response(403) + assert conn |> with_token(user, ["read:statuses"]) |> get(inbox) |> json_response(200) + end + + test "POST /users/:nickname/outbox needs write", %{conn: conn, user: user} do + activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Create", + "object" => %{ + "type" => "Note", + "content" => "scoped", + "to" => "https://www.w3.org/ns/activitystreams#Public", + "cc" => [] + } + } + + post = fn scopes -> + conn + |> with_token(user, scopes) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/outbox", activity) + end + + assert post.(["profile", "write:media"]) |> json_response(403) + assert post.(["write"]) |> json_response(201) + end + + test "POST /api/ap/upload_media is forbidden without write:media", %{conn: conn, user: user} do + assert conn + |> with_token(user, ["write:statuses"]) + |> post("/api/ap/upload_media", %{}) + |> json_response(403) + end + + # 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 works with write:media", %{conn: conn, user: user} do + image = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + assert conn + |> with_token(user, ["write:media"]) + |> post("/api/ap/upload_media", %{"file" => image}) + |> json_response(201) + end + + test "GET /users/:nickname/outbox shows private posts only with read:statuses", + %{conn: conn, user: user} do + {:ok, activity} = CommonAPI.post(user, %{status: "secret", visibility: "private"}) + outbox = "/users/#{user.nickname}/outbox?page=true" + + ids = fn scopes -> + conn + |> with_token(user, scopes) + |> get(outbox) + |> json_response(200) + |> Map.fetch!("orderedItems") + |> Enum.map(& &1["id"]) + end + + refute activity.data["id"] in ids.(["profile"]) + assert activity.data["id"] in ids.(["read:statuses"]) + end + + test "GET /users/:nickname/followers shows hidden followers only with read:accounts", + %{conn: conn} do + user = insert(:user, hide_followers: true) + User.follow(insert(:user), user) + followers = "/users/#{user.nickname}/followers?page=1" + + assert conn |> with_token(user, ["profile"]) |> get(followers) |> response(403) + + assert %{"orderedItems" => [_]} = + conn |> with_token(user, ["read:accounts"]) |> get(followers) |> json_response(200) + end + + test "GET /users/:nickname/following shows hidden follows only with read:accounts", + %{conn: conn} do + user = insert(:user, hide_follows: true) + User.follow(user, insert(:user)) + following = "/users/#{user.nickname}/following?page=1" + + assert conn |> with_token(user, ["profile"]) |> get(following) |> response(403) + + assert %{"orderedItems" => [_]} = + conn |> with_token(user, ["read:accounts"]) |> get(following) |> json_response(200) + end + end end diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index ed6db558b..c118cd8f1 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -244,6 +244,58 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do } end + test "rejects actors whose inbox is on another host" do + ap_id = "https://example.com/users/evil" + + user_data = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "evil") + |> Jason.decode!() + |> Map.put("inbox", "https://victim.example/inbox") + |> Map.put("featured", nil) + |> Jason.encode!() + + Tesla.Mock.mock(fn + %{method: :get, url: ^ap_id} -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + assert {:error, _} = ActivityPub.make_user_from_ap_id(ap_id) + refute User.get_by_ap_id(ap_id) + end + + test "updates the known user when fetched from a display URL" do + ap_id = "https://example.com/users/lain" + display_url = "https://example.com/@lain" + user = insert(:user, local: false, ap_id: ap_id, nickname: "lain@example.com") + + user_data = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + |> Jason.decode!() + |> Map.merge(%{"featured" => nil, "name" => "new name"}) + |> Jason.encode!() + + Tesla.Mock.mock(fn %{method: :get, url: url} when url in [ap_id, display_url] -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + assert {:ok, %User{id: id, name: "new name"}} = + ActivityPub.make_user_from_ap_id(display_url) + + assert id == user.id + end + test "fetches user featured collection" do ap_id = "https://example.com/users/lain" @@ -516,7 +568,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do end test "fetches user birthday information from misskey" do - user_id = "https://misskey.io/@mkljczk" + user_id = "https://misskey.io/users/8dhi2ne167" Tesla.Mock.mock(fn %{ @@ -2033,7 +2085,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do json( %{ "@context" => "https://www.w3.org/ns/activitystreams", - "id" => "https://remote.org/users/masto_hidden_counters/followers" + "id" => "https://remote.org/users/masto_hidden_counters/following" }, headers: HttpRequestMock.activitypub_object_headers() ) @@ -2045,7 +2097,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do json( %{ "@context" => "https://www.w3.org/ns/activitystreams", - "id" => "https://remote.org/users/masto_hidden_counters/following" + "id" => "https://remote.org/users/masto_hidden_counters/followers" }, headers: HttpRequestMock.activitypub_object_headers() ) diff --git a/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs b/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs index 7a67645aa..119a54717 100644 --- a/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs @@ -8,6 +8,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do alias Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy alias Pleroma.Web.ActivityPub.Visibility + require Pleroma.Constants + setup do: clear_config(:mrf_object_age, threshold: 172_800, @@ -79,7 +81,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do {:ok, data} = ObjectAgePolicy.filter(data) - assert Visibility.get_visibility(%{data: data}) == "unlisted" + assert Visibility.get_visibility(%{data: data}) == "direct" end test "it delists an old post" do @@ -94,6 +96,90 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do assert Visibility.get_visibility(%{data: data}) == "unlisted" end + test "it keeps an old followers-only post private" do + clear_config([:mrf_object_age, :actions], [:delist]) + + data = get_old_message() + {:ok, user} = User.get_or_fetch_by_ap_id(data["actor"]) + + data = + data + |> Map.put("to", [user.follower_address]) + |> Map.put("cc", []) + |> put_in(["object", "to"], [user.follower_address]) + |> put_in(["object", "cc"], []) + + {:ok, data} = ObjectAgePolicy.filter(data) + + assert data["to"] == [user.follower_address] + assert data["cc"] == [] + assert Visibility.get_visibility(%{data: data}) == "private" + assert data["object"]["to"] == data["to"] + assert data["object"]["cc"] == data["cc"] + end + + test "it keeps an old direct message direct" do + clear_config([:mrf_object_age, :actions], [:delist]) + + data = get_old_message() + {:ok, _user} = User.get_or_fetch_by_ap_id(data["actor"]) + recipient = "https://example.com/users/someone" + + data = + data + |> Map.put("to", [recipient]) + |> Map.put("cc", []) + |> put_in(["object", "to"], [recipient]) + |> put_in(["object", "cc"], []) + + {:ok, data} = ObjectAgePolicy.filter(data) + + assert data["to"] == [recipient] + assert data["cc"] == [] + assert Visibility.get_visibility(%{data: data}) == "direct" + end + + test "it removes every copy of the follower address" do + clear_config([:mrf_object_age, :actions], [:strip_followers]) + + data = get_old_message() + {:ok, user} = User.get_or_fetch_by_ap_id(data["actor"]) + public = Pleroma.Constants.as_public() + followers = [user.follower_address, user.follower_address] + + data = + data + |> Map.put("to", [public]) + |> Map.put("cc", followers) + |> put_in(["object", "to"], [public]) + |> put_in(["object", "cc"], followers) + + {:ok, data} = ObjectAgePolicy.filter(data) + + assert data["to"] == [public] + assert data["cc"] == [] + end + + test "it does not duplicate addresses of an old unlisted post" do + clear_config([:mrf_object_age, :actions], [:delist, :strip_followers]) + + data = get_old_message() + {:ok, user} = User.get_or_fetch_by_ap_id(data["actor"]) + public = Pleroma.Constants.as_public() + + data = + data + |> Map.put("to", [user.follower_address]) + |> Map.put("cc", [public]) + |> put_in(["object", "to"], [user.follower_address]) + |> put_in(["object", "cc"], [public]) + + {:ok, data} = ObjectAgePolicy.filter(data) + + assert data["to"] == [] + assert data["cc"] == [public] + end + test "it allows a new post" do clear_config([:mrf_object_age, :actions], [:delist]) diff --git a/test/pleroma/web/activity_pub/mrf_test.exs b/test/pleroma/web/activity_pub/mrf_test.exs index 2d0f3b317..d897e529d 100644 --- a/test/pleroma/web/activity_pub/mrf_test.exs +++ b/test/pleroma/web/activity_pub/mrf_test.exs @@ -139,4 +139,37 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do type: :group } end + + describe "instance_allowed?/1" do + setup do: clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + setup do: clear_config([:mrf_simple, :reject], [{"rejected.example", "spam"}]) + setup do: clear_config([:mrf_simple, :accept], []) + + test "is false for hosts rejected by SimplePolicy, including subdomains" do + clear_config([:mrf_simple, :reject], [{"*.rejected.example", "spam"}]) + + refute MRF.instance_allowed?("rejected.example") + refute MRF.instance_allowed?("sub.rejected.example") + assert MRF.instance_allowed?("fine.example") + end + + test "only allows accepted hosts when SimplePolicy has an accept list" do + clear_config([:mrf_simple, :accept], [{"friend.example", ""}]) + + assert MRF.instance_allowed?("friend.example") + refute MRF.instance_allowed?("stranger.example") + end + + test "ignores the SimplePolicy lists when the policy is not enabled" do + clear_config([:mrf, :policies], []) + clear_config([:mrf_simple, :accept], [{"friend.example", ""}]) + + assert MRF.instance_allowed?("rejected.example") + assert MRF.instance_allowed?("stranger.example") + end + + test "is false without a host" do + refute MRF.instance_allowed?(nil) + end + end end diff --git a/test/pleroma/web/activity_pub/object_validators/user_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/user_validator_test.exs new file mode 100644 index 000000000..fbc89462e --- /dev/null +++ b/test/pleroma/web/activity_pub/object_validators/user_validator_test.exs @@ -0,0 +1,75 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidatorTest do + use ExUnit.Case, async: true + + alias Pleroma.Web.ActivityPub.ObjectValidators.UserValidator + + defp actor do + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + |> Jason.decode!() + end + + test "accepts an actor whose endpoints and collections are on its own host" do + data = actor() + assert {:ok, ^data, []} = UserValidator.validate(data, []) + end + + test "accepts an actor without optional fields" do + data = Map.drop(actor(), ["endpoints", "featured", "followers", "following", "publicKey"]) + assert {:ok, _, _} = UserValidator.validate(data, []) + end + + test "accepts all actor fixtures" do + for path <- Path.wildcard("test/fixtures/**/*.json"), + {:ok, %{"inbox" => _, "id" => id} = data} when is_binary(id) <- + [Jason.decode(File.read!(path))] do + assert {{:ok, _, _}, ^path} = {UserValidator.validate(data, []), path} + end + end + + test "treats empty and null fields as missing" do + data = + actor() + |> Map.merge(%{"inbox" => "", "followers" => nil, "following" => "", "featured" => ""}) + |> put_in(["endpoints", "sharedInbox"], "") + |> put_in(["publicKey", "id"], "") + + assert {:ok, _, _} = UserValidator.validate(data, []) + end + + for field <- ["inbox", "followers", "following", "featured"] do + test "rejects an actor whose #{field} is on another host" do + data = Map.put(actor(), unquote(field), "https://victim.example/users/lain/x") + assert {:error, _} = UserValidator.validate(data, []) + end + end + + test "rejects an actor whose sharedInbox is on another host" do + data = put_in(actor(), ["endpoints", "sharedInbox"], "https://victim.example/inbox") + assert {:error, _} = UserValidator.validate(data, []) + end + + test "rejects an actor whose collection is an object on another host" do + data = Map.put(actor(), "followers", %{"id" => "https://victim.example/users/v/followers"}) + assert {:error, _} = UserValidator.validate(data, []) + end + + test "rejects an actor whose key id is on another host" do + data = put_in(actor(), ["publicKey", "id"], "https://victim.example/users/lain#main-key") + assert {:error, _} = UserValidator.validate(data, []) + end + + test "rejects an actor whose key id has no host" do + data = put_in(actor(), ["publicKey", "id"], "lain") + assert {:error, _} = UserValidator.validate(data, []) + end + + test "rejects an actor without an id" do + assert {:error, _} = UserValidator.validate(Map.delete(actor(), "id"), []) + end +end diff --git a/test/pleroma/web/activity_pub/publisher_test.exs b/test/pleroma/web/activity_pub/publisher_test.exs index 447ffda1b..e8b6ecf38 100644 --- a/test/pleroma/web/activity_pub/publisher_test.exs +++ b/test/pleroma/web/activity_pub/publisher_test.exs @@ -32,8 +32,26 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do refute Publisher.should_federate?(nil, true) end - test "it returns true when public is true" do - assert Publisher.should_federate?(false, true) + test "it returns true for public activities to quarantined instances" do + clear_config([:instance, :quarantined_instances], [{"quarantined.example", ""}]) + + assert Publisher.should_federate?("https://quarantined.example/inbox", true) + refute Publisher.should_federate?("https://quarantined.example/inbox", false) + end + + test "it returns false for instances rejected by SimplePolicy, even for public activities" do + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + clear_config([:mrf_simple, :reject], [{"rejected.example", ""}]) + + refute Publisher.should_federate?("https://rejected.example/inbox", true) + refute Publisher.should_federate?("https://rejected.example/inbox", false) + assert Publisher.should_federate?("https://fine.example/inbox", true) + end + + test "it returns false for inboxes without a host" do + clear_config([:instance, :quarantined_instances], [{"quarantined.example", ""}]) + + refute Publisher.should_federate?("inbox", false) end end @@ -204,6 +222,32 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do ) end + test "doesn't publish a public activity to instances rejected by SimplePolicy" do + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + clear_config([:mrf_simple, :reject], [{"rejected.example", "spam"}]) + + actor = insert(:user) + + for host <- ["rejected.example", "fine.example"] do + follower = insert(:user, local: false, inbox: "https://#{host}/users/nick/inbox") + {:ok, _, _} = Pleroma.User.follow(follower, actor) + end + + actor = refresh_record(actor) + + note_activity = + insert(:note_activity, user: actor, recipients: [@as_public, actor.follower_address]) + + assert :ok == Publisher.publish(actor, note_activity) + + inboxes = + all_enqueued(worker: "Pleroma.Workers.PublisherWorker") + |> Enum.map(& &1.args["params"]["inbox"]) + + assert "https://fine.example/users/nick/inbox" in inboxes + refute "https://rejected.example/users/nick/inbox" in inboxes + end + test "Publishes a non-public activity to non-quarantined instances." do Config.put([:instance, :quarantined_instances], [{"somedomain.com", "some reason"}]) diff --git a/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs index cf995e143..b39bc8780 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs @@ -11,8 +11,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do import Pleroma.Factory + # matches the host of the actor in mastodon-update.json + @gargron "http://mastodon.example.org/users/gargron" + test "it works for incoming update activities" do - user = insert(:user, local: false) + user = insert(:user, local: false, ap_id: @gargron) update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() @@ -50,8 +53,28 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do assert user.bio == "

Some bio

" end + test "it rejects updates that move the shared inbox to another host" do + user = insert(:user, local: false, ap_id: @gargron) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + update_data["object"] + |> Map.put("actor", user.ap_id) + |> Map.put("id", user.ap_id) + |> put_in(["endpoints", "sharedInbox"], "https://victim.example/inbox") + + update_data = + update_data + |> Map.put("actor", user.ap_id) + |> Map.put("object", object) + + assert {:error, _} = Transmogrifier.handle_incoming(update_data) + assert User.get_cached_by_ap_id(user.ap_id).shared_inbox == user.shared_inbox + end + test "it selects an actor type after an extension type" do - user = insert(:user, local: false) + user = insert(:user, local: false, ap_id: @gargron) update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() object = @@ -70,7 +93,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do end test "it works with alsoKnownAs" do - %{ap_id: actor} = insert(:user, local: false) + %{ap_id: actor} = insert(:user, local: false, ap_id: @gargron) assert User.get_cached_by_ap_id(actor).also_known_as == [] @@ -97,7 +120,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do end test "it works with custom profile fields" do - user = insert(:user, local: false) + user = insert(:user, local: false, ap_id: @gargron) assert user.fields == [] @@ -155,7 +178,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do end test "it works for incoming update activities which lock the account" do - user = insert(:user, local: false) + user = insert(:user, local: false, ap_id: @gargron) update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index e82d795e1..0ac33b477 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -13,6 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Plugs.SetLocalePlug @@ -2033,6 +2034,60 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert id == to_string(user.id) end + + test "the profile scope only gets the public account" do + user = insert(:user, default_scope: "private", pleroma_settings_store: %{"fe" => %{}}) + %{conn: conn} = oauth_access(["profile"], user: user) + + response = + conn + |> get("/api/v1/accounts/verify_credentials") + |> json_response_and_validate_schema(200) + + assert response == + AccountView.render("show.json", user: user, skip_visibility_check: true) + |> Jason.encode!() + |> Jason.decode!() + + refute Map.has_key?(response["source"], "privacy") + refute Map.has_key?(response, "follow_requests_count") + + for key <- ~w(email settings_store chat_token notification_settings + unread_notifications_count unread_conversation_count allow_following_move) do + refute Map.has_key?(response["pleroma"], key), "#{key} must not be shown" + end + end + + test "the profile scope with read:accounts gets the full account" do + %{conn: conn} = oauth_access(["profile", "read:accounts"]) + + response = + conn + |> get("/api/v1/accounts/verify_credentials") + |> json_response_and_validate_schema(200) + + assert response["source"]["privacy"] + assert response["pleroma"]["chat_token"] + end + + test "the profile scope works when unauthenticated users cannot see profiles" do + clear_config([:restrict_unauthenticated, :profiles, :local], true) + %{user: user, conn: conn} = oauth_access(["profile"]) + + assert %{"id" => id} = + conn + |> get("/api/v1/accounts/verify_credentials") + |> json_response_and_validate_schema(200) + + assert id == to_string(user.id) + end + + test "the profile scope does not give access to other endpoints" do + %{conn: conn} = oauth_access(["profile"]) + + assert conn |> get("/api/v1/timelines/home") |> json_response(403) + assert conn |> get("/api/v1/follow_requests") |> json_response(403) + end end describe "user relationships" do diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index f7b815ed7..6d1bda193 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -345,7 +345,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do bookmark_folder: nil, list_id: nil }, - quotes_count: 0 + quotes_count: 0, + quote: nil } assert status == expected @@ -507,6 +508,63 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do refute status.pleroma.quote_visible end + describe "Mastodon quote entity" do + test "a quote has the quoted status, and quoted statuses only a shallow quote" do + post = insert(:note_activity) + user = insert(:user) + + {:ok, quote_post} = CommonAPI.post(user, %{status: "he", quoted_status_id: post.id}) + + {:ok, quoted_quote_post} = + CommonAPI.post(user, %{status: "yo", quoted_status_id: quote_post.id}) + + status = StatusView.render("show.json", %{activity: quoted_quote_post}) + + assert %{state: "accepted", quoted_status: %{id: quoted_id} = quoted} = status.quote + assert quoted_id == to_string(quote_post.id) + assert quoted.quote == %{state: "accepted", quoted_status_id: to_string(post.id)} + + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + end + + test "a quote of a post the user cannot see is unauthorized" do + user = insert(:user) + private = insert(:followers_only_note_activity, user: user) + + quote_private = + insert(:note_activity, + note: insert(:note, data: %{"quoteUrl" => Object.normalize(private).data["id"]}) + ) + + status = StatusView.render("show.json", %{activity: quote_private}) + assert status.quote == %{state: "unauthorized", quoted_status: nil} + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + + follower = insert(:user) + CommonAPI.follow(user, follower) + + status = StatusView.render("show.json", %{activity: quote_private, for: follower}) + assert %{state: "accepted", quoted_status: %{id: id}} = status.quote + assert id == to_string(private.id) + end + + test "a quote of a missing post is deleted" do + quote_missing = + insert(:note_activity, + note: insert(:note, data: %{"quoteUrl" => "https://example.com/objects/gone"}) + ) + + status = StatusView.render("show.json", %{activity: quote_missing}) + assert status.quote == %{state: "deleted", quoted_status: nil} + end + + test "a post without a quote has no quote" do + status = StatusView.render("show.json", %{activity: insert(:note_activity)}) + assert status.quote == nil + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + end + end + test "repost of quote post" do post = insert(:note_activity) user = insert(:user) @@ -559,6 +617,46 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do assert mention.url == recipient_ap_id end + test "inlined images are media proxied" do + ConfigMock |> stub_with(Pleroma.Test.StaticConfig) + clear_config([:media_proxy, :enabled], true) + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + content_type: "text/html", + status: "hii " + }) + + activity = Repo.get(Activity, activity.id) + status = StatusView.render("show.json", activity: activity) + + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + + assert status[:content] =~ + ~r/^hii stub_with(Pleroma.Test.StaticConfig) + clear_config([:media_proxy, :enabled], true) + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + content_type: "text/html", + status: "hii " + }) + + activity = Repo.get(Activity, activity.id) + status = StatusView.render("show.json", activity: activity) + + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + + assert status[:content] =~ + ~r/^hii Enum.map(& &1.ap_id) @@ -597,6 +695,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do end describe "attachments" do + test "skips meta for zero dimensions" do + for {width, height} <- [{0, 0}, {200, 0}, {0, 100}] do + object = %{ + "type" => "Image", + "url" => [ + %{ + "mediaType" => "image/png", + "href" => "someurl", + "width" => width, + "height" => height + } + ] + } + + refute Map.has_key?(StatusView.render("attachment.json", %{attachment: object}), :meta) + end + end + test "Complete Mastodon style" do object = %{ "type" => "Image", diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index 86d30c04c..ab707a061 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -59,6 +59,20 @@ defmodule Pleroma.Web.MediaProxyTest do assert decode_result(encoded) == url end + test "encodes and decodes a network-path reference URL as HTTPS" do + url = "//example.org/static/logo.png" + encoded = MediaProxy.url(url) + + assert String.starts_with?( + encoded, + Config.get([:media_proxy, :base_url], Pleroma.Web.Endpoint.url()) + ) + + assert String.ends_with?(encoded, "/logo.png") + + assert decode_result(encoded) == "https:" <> url + end + test "encodes and decodes URL without a path" do url = "https://pleroma.soykaf.com" encoded = MediaProxy.url(url) diff --git a/test/pleroma/web/o_auth/token_controller_test.exs b/test/pleroma/web/o_auth/token_controller_test.exs index 5c64cb394..08acf27bf 100644 --- a/test/pleroma/web/o_auth/token_controller_test.exs +++ b/test/pleroma/web/o_auth/token_controller_test.exs @@ -52,36 +52,60 @@ defmodule Pleroma.Web.OAuth.TokenControllerTest do end describe "GET /api/oauth_tokens" do - setup do - token = insert(:oauth_token) |> Repo.preload(:user) - - %{token: token} - end - - test "renders list", %{token: token} do - response = - build_conn() - |> assign(:user, token.user) - |> get("/api/oauth_tokens") + test "renders list" do + %{conn: conn} = oauth_access(["read:security"]) keys = - json_response(response, 200) + conn + |> get("/api/oauth_tokens") + |> json_response(200) |> hd() |> Map.keys() assert Enum.sort(keys) == Enum.sort(["id", "app_name", "valid_until", "scopes"]) end - test "revoke token", %{token: token} do - response = - build_conn() - |> assign(:user, token.user) - |> delete("/api/oauth_tokens/#{token.id}") + test "is allowed with the read scope" do + %{conn: conn} = oauth_access(["read"]) - tokens = Token.get_user_tokens(token.user) + assert [_] = conn |> get("/api/oauth_tokens") |> json_response(200) + end - assert tokens == [] - assert response.status == 201 + test "is forbidden without read:security" do + %{conn: conn} = oauth_access(["profile", "read:accounts", "write:security"]) + + assert %{"error" => "Insufficient permissions: read:security."} = + conn |> get("/api/oauth_tokens") |> json_response(403) + end + end + + describe "DELETE /api/oauth_tokens/:id" do + test "revokes the token" do + %{conn: conn, user: user, token: token} = oauth_access(["write:security"]) + other = insert(:oauth_token, user: user) + + conn = delete(conn, "/api/oauth_tokens/#{other.id}") + + assert conn.status == 201 + assert [%Token{id: id}] = Token.get_user_tokens(user) + assert id == token.id + end + + test "is allowed with the write scope" do + %{conn: conn, user: user} = oauth_access(["write"]) + other = insert(:oauth_token, user: user) + + assert delete(conn, "/api/oauth_tokens/#{other.id}").status == 201 + end + + test "is forbidden without write:security" do + %{conn: conn, user: user} = oauth_access(["profile", "read", "write:accounts"]) + other = insert(:oauth_token, user: user) + + assert %{"error" => "Insufficient permissions: write:security."} = + conn |> delete("/api/oauth_tokens/#{other.id}") |> json_response(403) + + assert length(Token.get_user_tokens(user)) == 2 end end end diff --git a/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs b/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs new file mode 100644 index 000000000..1ee81b1a0 --- /dev/null +++ b/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.HTTPSignaturePlugMRFTest do + # Not async: the MRF settings are global config. + use Pleroma.Web.ConnCase + + alias Pleroma.StaticStubbedConfigMock, as: ConfigMock + alias Pleroma.StubbedHTTPSignaturesMock, as: HTTPSignaturesMock + alias Pleroma.Web.Plugs.HTTPSignaturePlug + + import Mox + import Phoenix.Controller, only: [put_format: 2] + import Plug.Conn + + setup do: clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + setup do: clear_config([:mrf_simple, :reject], [{"mastodon.example.org", "no reason"}]) + + defp signed_get(actor) do + ConfigMock + |> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end) + |> expect(:get, fn [:instance, :rejected_instances] -> [] end) + + HTTPSignaturesMock + |> expect(:validate_conn, fn _ -> true end) + + build_conn(:get, "/doesntmattter", %{"actor" => actor}) + |> put_req_header("signature", "keyId=\"#{actor}#main-key") + |> put_format("activity+json") + |> HTTPSignaturePlug.call(%{}) + end + + test "rejects signed requests from instances rejected by SimplePolicy" do + assert signed_get("http://mastodon.example.org/users/admin").halted + refute signed_get("http://allowed.example.org/users/admin").halted + end +end diff --git a/test/pleroma/web/plugs/rate_limiter_test.exs b/test/pleroma/web/plugs/rate_limiter_test.exs index 10c93fa73..058c501f5 100644 --- a/test/pleroma/web/plugs/rate_limiter_test.exs +++ b/test/pleroma/web/plugs/rate_limiter_test.exs @@ -50,7 +50,9 @@ defmodule Pleroma.Web.Plugs.RateLimiterTest do test "it restricts based on config values" do limiter_name = :test_plug_opts - scale = 80 + # Long enough that the bucket cannot expire during the test on slow CI. + # The test expires it explicitly with expire_ttl/2. + scale = 60_000 limit = 5 clear_config([Pleroma.Web.Endpoint, :http, :ip], {127, 0, 0, 1}) @@ -184,7 +186,8 @@ defmodule Pleroma.Web.Plugs.RateLimiterTest do test "can have limits separate from unauthenticated connections" do limiter_name = :test_authenticated1 - scale = 50 + # Long enough that the bucket cannot expire during the test on slow CI. + scale = 60_000 limit = 5 clear_config([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8}) clear_config([:rate_limit, limiter_name], [{1000, 1}, {scale, limit}]) diff --git a/test/pleroma/workers/publisher_worker_test.exs b/test/pleroma/workers/publisher_worker_test.exs index ddbbba910..4ee0772e0 100644 --- a/test/pleroma/workers/publisher_worker_test.exs +++ b/test/pleroma/workers/publisher_worker_test.exs @@ -153,4 +153,38 @@ defmodule Pleroma.Workers.PublisherWorkerTest do assert {:cancel, :unreachable} = PublisherWorker.perform(job) end end + + describe "Blocked instances:" do + defp publish_one_job(inbox, activity) do + %Oban.Job{ + args: %{ + "op" => "publish_one", + "params" => %{"inbox" => inbox, "activity_id" => activity.id} + }, + attempt: 1, + max_attempts: 5 + } + end + + test "cancels queued jobs to instances rejected by SimplePolicy" do + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + clear_config([:mrf_simple, :reject], [{"rejected.example", ""}]) + {:ok, activity} = CommonAPI.post(insert(:user), %{status: "public"}) + + assert {:cancel, :rejected} = + PublisherWorker.perform( + publish_one_job("https://rejected.example/inbox", activity) + ) + end + + test "cancels queued private jobs to quarantined instances" do + clear_config([:instance, :quarantined_instances], [{"quarantined.example", ""}]) + {:ok, activity} = CommonAPI.post(insert(:user), %{status: "hi", visibility: "private"}) + + assert {:cancel, :rejected} = + PublisherWorker.perform( + publish_one_job("https://quarantined.example/inbox", activity) + ) + end + end end diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 82affe7ac..94be59536 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -577,6 +577,16 @@ defmodule HttpRequestMock do }} end + # the same status via its canonical id + def get("http://mastodon.example.org/users/admin/statuses/99541947525187367", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/mastodon-note-object.json"), + headers: activitypub_object_headers() + }} + end + def get("http://mastodon.example.org/@admin/99541947525187368", _, _, _) do {:ok, %Tesla.Env{