From c686b701dd72f7bfbd02b8a62dd9e2d9d8b8f90c Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 12:21:47 +0400 Subject: [PATCH 01/32] fix(oauth): require security scopes to list and revoke tokens /api/oauth_tokens skipped the scope check, so a token with any scope could list the user's sessions and revoke them. --- changelog.d/oauth-tokens-scopes.security | 1 + docs/development/API/pleroma_api.md | 4 +- lib/pleroma/web/o_auth/token_controller.ex | 3 +- .../web/o_auth/token_controller_test.exs | 64 +++++++++++++------ 4 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 changelog.d/oauth-tokens-scopes.security 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/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/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/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 From 530ae4b339f006f500ad2b736a1016c4b444f510 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 12:26:07 +0400 Subject: [PATCH 02/32] feat(oauth): support the profile scope A token with only the profile scope can call verify_credentials, as in Mastodon 4.3. The answer is the public account, so apps can use it to log users in without seeing their email or settings. --- changelog.d/oauth-profile-scope.add | 1 + .../API/differences_in_mastoapi_responses.md | 4 ++ .../api_spec/operations/account_operation.ex | 2 +- .../controllers/account_controller.ex | 22 +++++++- priv/gettext/en/LC_MESSAGES/oauth_scopes.po | 5 ++ priv/gettext/oauth_scopes.pot | 5 ++ .../controllers/account_controller_test.exs | 55 +++++++++++++++++++ 7 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 changelog.d/oauth-profile-scope.add 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/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/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/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/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/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 From dd42b02ca2338d4dd52f47babdf0625c5fec26ec Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 12:44:32 +0400 Subject: [PATCH 03/32] chore: remove unrouted PleromaAPI token and password controllers Leftover copies from 120719f28; the router uses the OAuth ones. The token copy still skipped the OAuth scope check. --- .../remove-dead-pleroma-api-controllers.skip | 0 .../controllers/password_controller.ex | 52 ---------------- .../controllers/token_controller.ex | 59 ------------------- 3 files changed, 111 deletions(-) create mode 100644 changelog.d/remove-dead-pleroma-api-controllers.skip delete mode 100644 lib/pleroma/web/pleroma_api/controllers/password_controller.ex delete mode 100644 lib/pleroma/web/pleroma_api/controllers/token_controller.ex 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/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 From da7e75ca731dc407e19042619c90ed0868dbdf66 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 12:46:59 +0400 Subject: [PATCH 04/32] fix(activitypub): check OAuth scopes on C2S endpoints The C2S routes and the outbox/followers/following/replies collections accepted a token with any scope. Requests with an OAuth token now need the matching scope; HTTP signatures and Basic auth are not changed. --- changelog.d/ap-c2s-oauth-scopes.security | 1 + .../activity_pub/activity_pub_controller.ex | 25 ++++ .../activity_pub_controller_test.exs | 121 ++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 changelog.d/ap-c2s-oauth-scopes.security 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/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/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 From 90479b3415b6692cc5c903a606610a5371bf11fb Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 21:48:31 +0400 Subject: [PATCH 05/32] Sec: Update bandit to version 1.14.0 --- changelog.d/bandit-1.14.0.security | 1 + mix.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/bandit-1.14.0.security diff --git a/changelog.d/bandit-1.14.0.security b/changelog.d/bandit-1.14.0.security new file mode 100644 index 000000000..f2161ae86 --- /dev/null +++ b/changelog.d/bandit-1.14.0.security @@ -0,0 +1 @@ +Update Bandit to version 1.14.0, which fixes HTTP/1.1, HTTP/2 and WebSocket denial-of-service and request smuggling vulnerabilities diff --git a/mix.lock b/mix.lock index 2f2cce9e2..2d47078da 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"}, From eabdde8a2086759c7f39976e3780974dc5196a9d Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 21:52:03 +0400 Subject: [PATCH 06/32] Sec: Update hackney to version 4.9.1 Hackney 4.9 enforces max_body while it reads and returns {:error, {:closed, partial}} for a truncated body; the hackney regression tests now pin that. The certifi override goes, as hackney itself now asks for certifi ~> 2.18.0. --- changelog.d/hackney-4.9.1.security | 1 + mix.exs | 4 +--- mix.lock | 8 ++++---- test/pleroma/http/hackney_conn_leak_test.exs | 11 +++++----- .../http/hackney_max_body_regression_test.exs | 20 +++++++++---------- 5 files changed, 20 insertions(+), 24 deletions(-) create mode 100644 changelog.d/hackney-4.9.1.security diff --git a/changelog.d/hackney-4.9.1.security b/changelog.d/hackney-4.9.1.security new file mode 100644 index 000000000..568903273 --- /dev/null +++ b/changelog.d/hackney-4.9.1.security @@ -0,0 +1 @@ +Update Hackney to version 4.9.1, which fixes unbounded response body buffering and decompression, CA pinning, redirect credential leaks and other vulnerabilities diff --git a/mix.exs b/mix.exs index d6c2be21f..f2014ec3b 100644 --- a/mix.exs +++ b/mix.exs @@ -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 2d47078da..f8ce53f4c 100644 --- a/mix.lock +++ b/mix.lock @@ -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/test/pleroma/http/hackney_conn_leak_test.exs b/test/pleroma/http/hackney_conn_leak_test.exs index f83cad73d..ce1184453 100644 --- a/test/pleroma/http/hackney_conn_leak_test.exs +++ b/test/pleroma/http/hackney_conn_leak_test.exs @@ -14,7 +14,8 @@ # # 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 a truncated read also reported `{:ok, body}`; +# since 4.9 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 @@ -50,11 +51,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 +69,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..0fc456755 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 @@ -62,12 +63,11 @@ defmodule Pleroma.HTTP.HackneyMaxBodyRegressionTest do 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 From 48ec8518a31f61203d7e8726a4da0a7244fa5695 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 22:02:18 +0400 Subject: [PATCH 07/32] fix(http): drop the partial body from truncated-read errors Hackney 4.9 returns {:error, {:closed, partial}}. RelMe caches errors, so it would keep up to max_body bytes per URL. --- lib/pleroma/http.ex | 6 ++++++ test/pleroma/http_test.exs | 10 ++++++++++ 2 files changed, 16 insertions(+) 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/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"}]) == From 26486b041b577a5694684f3975736c839ee4ae5a Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 22:02:41 +0400 Subject: [PATCH 08/32] docs(http): update hackney comments for 4.9 --- lib/pleroma/tesla/middleware/max_body.ex | 16 +++++++--------- test/pleroma/http/hackney_conn_leak_test.exs | 9 ++++----- .../http/hackney_max_body_regression_test.exs | 6 +++--- 3 files changed, 14 insertions(+), 17 deletions(-) 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/test/pleroma/http/hackney_conn_leak_test.exs b/test/pleroma/http/hackney_conn_leak_test.exs index ce1184453..424385dac 100644 --- a/test/pleroma/http/hackney_conn_leak_test.exs +++ b/test/pleroma/http/hackney_conn_leak_test.exs @@ -14,16 +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. Up to 4.8 a truncated read also reported `{:ok, body}`; -# since 4.9 it is `{:error, {:closed, partial_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 diff --git a/test/pleroma/http/hackney_max_body_regression_test.exs b/test/pleroma/http/hackney_max_body_regression_test.exs index 0fc456755..27fe2cddb 100644 --- a/test/pleroma/http/hackney_max_body_regression_test.exs +++ b/test/pleroma/http/hackney_max_body_regression_test.exs @@ -44,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) @@ -58,7 +58,7 @@ 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 From 006ad1c7d07c8b3036ebb3a026a955cd4136d267 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 22:02:47 +0400 Subject: [PATCH 09/32] Prepare 2.11.1 release --- CHANGELOG.md | 7 +++++++ changelog.d/bandit-1.14.0.security | 1 - changelog.d/hackney-4.9.1.security | 1 - changelog.d/{release-2.11.0.skip => release-2.11.1.skip} | 0 mix.exs | 2 +- 5 files changed, 8 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/bandit-1.14.0.security delete mode 100644 changelog.d/hackney-4.9.1.security rename changelog.d/{release-2.11.0.skip => release-2.11.1.skip} (100%) 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/bandit-1.14.0.security b/changelog.d/bandit-1.14.0.security deleted file mode 100644 index f2161ae86..000000000 --- a/changelog.d/bandit-1.14.0.security +++ /dev/null @@ -1 +0,0 @@ -Update Bandit to version 1.14.0, which fixes HTTP/1.1, HTTP/2 and WebSocket denial-of-service and request smuggling vulnerabilities diff --git a/changelog.d/hackney-4.9.1.security b/changelog.d/hackney-4.9.1.security deleted file mode 100644 index 568903273..000000000 --- a/changelog.d/hackney-4.9.1.security +++ /dev/null @@ -1 +0,0 @@ -Update Hackney to version 4.9.1, which fixes unbounded response body buffering and decompression, CA pinning, redirect credential leaks and other vulnerabilities diff --git a/changelog.d/release-2.11.0.skip b/changelog.d/release-2.11.1.skip similarity index 100% rename from changelog.d/release-2.11.0.skip rename to changelog.d/release-2.11.1.skip diff --git a/mix.exs b/mix.exs index f2014ec3b..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(), From 2a55239ca816a78428bfba25cf078b5a8a56fec2 Mon Sep 17 00:00:00 2001 From: Oneric Date: Sat, 16 Nov 2024 00:43:38 +0100 Subject: [PATCH 10/32] mrf/object_age: fix handling of non-public objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current logic unconditionally adds public adressing to "cc" and follower adressing to "to" after attempting to strip it from the other one. This creates serious problems: First the bug prompting this investigation and fix, unconditional addition creates duplicates when adressing URIs already were in their intended final field; e.g. this is prominently the case for all "unlisted" posts. Since List.delete only removes the first occurence, this then broke follower-adress stripping later on making the policy ineffective. It’s also just not safe in general wrt to non-public adressing: e.g. pre-existing duplicates didn’t get fully stripped, bespoke adressing modes with only one of public addressing or follower addressing are mangled — and most importantly: any belatedly received DM or follower-only post also got public adressing added! Shockingly this last point was actually asserted as "correct" in tests; it appears to be a mistake from mindless match adjustments while fixing crashes on nil adressing in 10c792110e6ea8ed21f739ef8f4f0eff4659ebf9. Clean up this sloppy logic up, making sure no more duplicates are added by us, all instances of relevant adresses are purged and only readded when they actually existed to begin with. (cherry picked from commit 932810c35ecba9d11fa4f11112c3444b62c45b65) --- .../web/activity_pub/mrf/object_age_policy.ex | 34 ++++++++++++++----- .../mrf/object_age_policy_test.exs | 2 +- 2 files changed, 27 insertions(+), 9 deletions(-) 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/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..8c463f7f2 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 @@ -79,7 +79,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 From df5e756862a52372a48d60652f594186a4704d71 Mon Sep 17 00:00:00 2001 From: Oneric Date: Wed, 17 Jun 2026 00:00:00 +0000 Subject: [PATCH 11/32] Do not notify users unable to access post Known implementations all intend to only tag users who are _also_ allowed to access the post, but this is not guaranteed by ActivityPub semantics, thus recheck against the authorative AP addressing fields. An alternative worth considering is directly using the addressing fields to decide whom to notify ignoring tags. However, MastoAPI later uses tags and thus this may lead to confusing results if a recipient is addressed but not tagged and receives a notification about being mentioned while the API claims they are not mentioned. Addresses the receiving side of: https://akkoma.dev/AkkomaGang/akkoma/issues/1135 (cherry picked from commit 5cff1fac24bb3e6c22160202a7b846f8a62f2477) --- lib/pleroma/web/common_api/utils.ex | 43 ++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 255803c14..64958e1f2 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -435,13 +435,54 @@ 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 = author.follower_address in recipients + + Enum.filter(tagged_mentions, fn tagged -> + cond do + tagged in recipients -> + true + + to_followers -> + tagged_user = User.get_cached_by_ap_id(tagged) + User.following?(tagged_user, author) + + 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 From 0d93c903f7917e10cf5c2676463efaef4a6428b5 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:27:09 +0400 Subject: [PATCH 12/32] test(mrf): cover ObjectAgePolicy with private, direct and unlisted posts --- changelog.d/object-age-non-public.security | 1 + .../mrf/object_age_policy_test.exs | 86 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 changelog.d/object-age-non-public.security 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/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs b/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs index 8c463f7f2..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, @@ -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]) From f975db87191fae2d1e40d8736d49434352a71344 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:28:52 +0400 Subject: [PATCH 13/32] fix(filters): treat phrases as literal text in the regex Phrases went into the Postgres and Elixir regexes as they were, so a phrase like "(" broke the timeline and notification queries. The :re form also put the word boundaries only around the whole alternation. Escape each phrase and, as Mastodon does, add a word boundary only where the phrase starts or ends with a word character. Reported in Akkoma (d6209837b). --- changelog.d/filter-regex-escape.fix | 1 + lib/pleroma/filter.ex | 20 +++++++---- test/pleroma/filter_test.exs | 51 +++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 changelog.d/filter-regex-escape.fix 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/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 77ed64d4f..3ab44efb0 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -208,19 +208,27 @@ 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.phrase, 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, only add a word boundary + # where the phrase starts or ends with a word character. + defp phrase_regex(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/test/pleroma/filter_test.exs b/test/pleroma/filter_test.exs index 6d33cd22b..5018b30bc 100644 --- a/test/pleroma/filter_test.exs +++ b/test/pleroma/filter_test.exs @@ -190,4 +190,55 @@ 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 "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 From a766335679428b4e99ef98f0401e74f57b15433c Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:33:35 +0400 Subject: [PATCH 14/32] fix(federation): do not deliver to instances rejected by SimplePolicy Public activities went to every inbox, so followers on an instance in the SimplePolicy reject list still got our posts. With authorized fetch mode on, signed fetches from those instances were also accepted, unless the instance was in :instance, :rejected_instances too. Check the SimplePolicy reject and accept lists for deliveries and signed fetches, when the policy is enabled. Quarantine keeps its meaning (no private activities). An inbox without a host no longer crashes. Based on the Akkoma changes 90c4785b8, ca000f830, 03662501c and d2b0d8647. Co-authored-by: FloatingGhost Co-authored-by: Atsuko Karagi --- .../federation-reject-instances.security | 1 + docs/configuration/cheatsheet.md | 4 +- lib/pleroma/web/activity_pub/mrf.ex | 33 +++++++++++++ .../web/activity_pub/mrf/simple_policy.ex | 4 +- lib/pleroma/web/activity_pub/publisher.ex | 32 ++++++++----- lib/pleroma/web/plugs/http_signature_plug.ex | 2 +- test/pleroma/web/activity_pub/mrf_test.exs | 33 +++++++++++++ .../web/activity_pub/publisher_test.exs | 48 ++++++++++++++++++- .../plugs/http_signature_plug_mrf_test.exs | 38 +++++++++++++++ 9 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 changelog.d/federation-reject-instances.security create mode 100644 test/pleroma/web/plugs/http_signature_plug_mrf_test.exs 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/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/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/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/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/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/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/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/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 From cccbb39309104263fc7ebaf4b7c196ab3d9a9ac5 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:31:18 +0400 Subject: [PATCH 15/32] fix(notifications): handle unknown users when filtering tagged mentions The access check from the previous commit crashed on tags of unknown users in followers-only posts, so such posts failed to be received. Also add tests for the check. --- changelog.d/mention-notify-access.security | 1 + lib/pleroma/web/common_api/utils.ex | 8 +- test/pleroma/notification_test.exs | 98 ++++++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 changelog.d/mention-notify-access.security 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/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 64958e1f2..b66e0ace6 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -465,7 +465,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do true -> author = User.get_cached_by_ap_id(actor) - to_followers = author.follower_address in recipients + to_followers = match?(%User{}, author) and author.follower_address in recipients Enum.filter(tagged_mentions, fn tagged -> cond do @@ -473,8 +473,10 @@ defmodule Pleroma.Web.CommonAPI.Utils do true to_followers -> - tagged_user = User.get_cached_by_ap_id(tagged) - User.following?(tagged_user, author) + case User.get_cached_by_ap_id(tagged) do + %User{} = tagged_user -> User.following?(tagged_user, author) + _ -> false + end true -> false diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 992b15f8b..694af27aa 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.NotificationTest do import Pleroma.Factory + require Pleroma.Constants + alias Pleroma.FollowingRelationship alias Pleroma.Notification alias Pleroma.Repo @@ -611,6 +613,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) From 422a1d1475bd3db41a05b1a36036f8ae0c27b3c9 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:50:04 +0400 Subject: [PATCH 16/32] fix(filters): honour whole_word The whole_word field was stored and returned by the API, but the regex always used word boundaries. As in Mastodon, a filter with whole_word false now also matches inside words. --- changelog.d/filter-whole-word.fix | 1 + lib/pleroma/filter.ex | 11 +++++++---- test/pleroma/filter_test.exs | 11 +++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 changelog.d/filter-whole-word.fix 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/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 3ab44efb0..fe3fb1c27 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -212,7 +212,7 @@ defmodule Pleroma.Filter do phrases = filters - |> Enum.map(&phrase_regex(&1.phrase, boundary)) + |> Enum.map(&phrase_regex(&1, boundary)) |> Enum.join("|") case format do @@ -223,9 +223,12 @@ defmodule Pleroma.Filter do def compose_regex(_, _), do: nil - # Phrases are literal text. Like Mastodon, only add a word boundary - # where the phrase starts or ends with a word character. - defp phrase_regex(phrase, boundary) do + # 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: "" diff --git a/test/pleroma/filter_test.exs b/test/pleroma/filter_test.exs index 5018b30bc..2fc72d9f5 100644 --- a/test/pleroma/filter_test.exs +++ b/test/pleroma/filter_test.exs @@ -233,6 +233,17 @@ defmodule Pleroma.FilterTest do 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) From 04fbb59932e497cbab6891f4dc664f1ed47b5f02 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:51:14 +0400 Subject: [PATCH 17/32] fix(federation): cancel queued deliveries to blocked instances Delivery jobs only checked reachability, so a job queued (or retrying) before the admin rejected or quarantined an instance was still sent. Check should_federate? again when the job runs. --- lib/pleroma/workers/publisher_worker.ex | 49 ++++++++++++------- .../pleroma/workers/publisher_worker_test.exs | 34 +++++++++++++ 2 files changed, 66 insertions(+), 17 deletions(-) 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/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 From 21a5d1dca2edfda01d75038ec4452c7ea8967428 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:53:21 +0400 Subject: [PATCH 18/32] fix(notifications): skip the filter check for objects without content Regex.match? raised on objects without content (for example posts with only attachments), so no notifications were created for them when the recipient had an irreversible filter. --- changelog.d/notification-filter-nil-content.fix | 1 + lib/pleroma/notification.ex | 3 +++ test/pleroma/notification_test.exs | 17 +++++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 changelog.d/notification-filter-nil-content.fix 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/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/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 992b15f8b..1ff014a8d 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.NotificationTest do alias Pleroma.FollowingRelationship alias Pleroma.Notification + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User @@ -276,6 +277,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) From 985ad29724636ae66ec217f4075aa992b0925e06 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 16:10:14 +0400 Subject: [PATCH 19/32] test(rate_limiter): use windows that cannot expire during the test The bucket entry expires one window after the first request. With a 50 or 80 ms window, a slow CI run could lose it between two requests, so inspect_bucket/3 returned {0, 5} (seen on #8021 with Elixir 1.17). The reset test expires the bucket itself, so a long window costs nothing. --- changelog.d/rate-limiter-test-flake.skip | 0 test/pleroma/web/plugs/rate_limiter_test.exs | 7 +++++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 changelog.d/rate-limiter-test-flake.skip diff --git a/changelog.d/rate-limiter-test-flake.skip b/changelog.d/rate-limiter-test-flake.skip new file mode 100644 index 000000000..e69de29bb 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}]) From f5898ab0fb25cf1f7282fc7849303e0ec9696174 Mon Sep 17 00:00:00 2001 From: Oneric Date: Fri, 6 Mar 2026 00:00:00 +0000 Subject: [PATCH 20/32] Fix malformed is_active qualifiers in User.Query usages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While "is_active" is a property of users, it is not a recognised keyword for the User.Query helper; instead "deactivated" with negated value must be used. This arose because originally the user property was also called "deactivated" until it was renamed adn negated five years ago in 860b5c78048ede3597a02b6029634d74fd520204. This included renaiming the parameter in most but not all User.Query usages. Notably the parameter in User.get_followers_query was renamed but not in User.get_friends_query (friends == following). The accepted query parameter in User.Query however was not changed. This lead to the former mistakenly including deleted users causing too large values to be reported in the ActivityPub follower, but not following collection as reported in #1078. In Masto API responses filtering by `User.is_visible` weeded out the extra accounts before they got displayed to API users. On the surface it might seem logical to align the name of the User.Query parameter with the actual property name. However, User.Query already accepts an "active" parameter which is an alias for limiting to accounts which are neither deleted nor deactivated by moderators (both indicated by is_active) and also are not newly created account requests still pending an admin approval (is_approved) or necessary email confirmation (is_confirmed); in short as the alias suggests whether the account is active. Two highly similar parameter names like this would be much too confusing. The renamed "is_active" on the other hand does not actually suffice to say whether an account is actually active, only whether it has (not yet) ceased to be active (by its own volition or moderator action). Meaning its "new" name is actively misleading. Arguably the rename made things worse for no reason whatsoever and should not ever have happened. For now, we’ll just revert the incorrect query helper parameter renames. Fixes: https://akkoma.dev/AkkomaGang/akkoma/ issues/ 1078 (cherry picked from commit a899663ddc0e3a2084a239eabd79608a94ac5493) --- lib/mix/tasks/pleroma/email.ex | 2 +- lib/mix/tasks/pleroma/user.ex | 4 +-- lib/pleroma/user.ex | 10 ++++---- test/pleroma/user_test.exs | 45 ++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 8 deletions(-) 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/user.ex b/lib/pleroma/user.ex index 3adc90c67..a8e08e335 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 @@ -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/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index ec5a2a12a..86e873037 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 From 555d93bc33aaca81212850d688952b29c5241f78 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 17:26:20 +0400 Subject: [PATCH 21/32] fix(user): use deactivated: false for familiar followers User.Query ignores is_active. The friends subquery already drops deactivated users here, so this only removes the last ignored key. Also add a changelog entry and a test. --- changelog.d/user-query-is-active.fix | 1 + lib/pleroma/user.ex | 2 +- test/pleroma/user_test.exs | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 changelog.d/user-query-is-active.fix 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/lib/pleroma/user.ex b/lib/pleroma/user.ex index a8e08e335..c0566c63d 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -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, diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 86e873037..32d582b50 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -3009,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 From 9443aba06ceed356fbafe869a5abb8a4ba0af510 Mon Sep 17 00:00:00 2001 From: Oneric Date: Sun, 15 Mar 2026 00:00:00 +0000 Subject: [PATCH 22/32] api/view: handle explicitly zero dimensions in attachments Some peertube videos are federated with explicitly zero dimensions for their mp4 link leading to a division-by-zero exception here. E.g.: https://spectra.video/videos/watch/66c12674-2013-47a4-9126-51ff8eb8d0a8 (as of writing using peertube 8.1.1) Reported by temp3 on IRC (cherry picked from commit 6833281051aa86b3d82232be29497fed47bf5fb1) --- lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 4625be47c..6a5d627a5 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -675,7 +675,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, From 283035ad4f9da126dae4205645662f3c83e6678a Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 17:27:41 +0400 Subject: [PATCH 23/32] test(status_view): cover attachments with zero dimensions --- changelog.d/attachment-zero-size.fix | 1 + .../mastodon_api/views/status_view_test.exs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 changelog.d/attachment-zero-size.fix 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/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index f7b815ed7..1a0d7a3ce 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -597,6 +597,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", From 5cd6894a66aa8e7f8a7406cfc840aa4ee8403205 Mon Sep 17 00:00:00 2001 From: Oneric Date: Mon, 14 Apr 2025 00:26:10 +0200 Subject: [PATCH 24/32] mediaproxy: proxy network-path references Discovered-by: snow Fixes: https://akkoma.dev/AkkomaGang/akkoma/issues/900 (cherry picked from commit bbf974adc82d09a2099fb1234729131929307911) --- lib/pleroma/web/media_proxy.ex | 6 ++- priv/scrubbers/media_proxy.ex | 4 ++ .../mastodon_api/views/status_view_test.exs | 40 +++++++++++++++++++ test/pleroma/web/media_proxy_test.exs | 14 +++++++ 4 files changed, 63 insertions(+), 1 deletion(-) 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/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/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index f7b815ed7..ca40f4946 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -559,6 +559,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) 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) From 0006a478bb031831961a2b488d809e8ff01b128c Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 17:30:08 +0400 Subject: [PATCH 25/32] chore: add changelog entry for network-path media proxy fix --- changelog.d/media-proxy-network-path.security | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/media-proxy-network-path.security 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 From 695dd340dec21dc9ddb72f908431f5d2c83ca6a6 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 21:37:20 +0400 Subject: [PATCH 26/32] fix(federation): reject actors that point at other hosts A remote actor could set its inbox or shared inbox to a third-party host, so our signed deliveries went there. It could also claim another actor's followers collection, which the visibility checks trust. Validate fetched actors and incoming actor Updates: inbox, endpoints.sharedInbox, followers, following, featured and the publicKey id must be on the same host as the actor id. Based on Akkoma f07eb4cb5. The Bridgy fixture gets the key id that current Bridgy Fed serves. --- changelog.d/actor-containment.security | 1 + lib/pleroma/object/containment.ex | 10 +++ lib/pleroma/web/activity_pub/activity_pub.ex | 2 + .../web/activity_pub/object_validator.ex | 19 +++++- .../object_validators/user_validator.ex | 52 +++++++++++++++ test/fixtures/bridgy/actor.json | 2 +- .../web/activity_pub/activity_pub_test.exs | 25 +++++++ .../object_validators/user_validator_test.exs | 65 +++++++++++++++++++ .../user_update_handling_test.exs | 33 ++++++++-- 9 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 changelog.d/actor-containment.security create mode 100644 lib/pleroma/web/activity_pub/object_validators/user_validator.ex create mode 100644 test/pleroma/web/activity_pub/object_validators/user_validator_test.exs 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/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index c4cc1f014..b50d560d1 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -112,4 +112,14 @@ defmodule Pleroma.Object.Containment do 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/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1279e0cb4..18cb2cadc 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 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..183be4940 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/user_validator.ex @@ -0,0 +1,52 @@ +# 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. + + 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, 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/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/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index ed6db558b..125a2b4f9 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -244,6 +244,31 @@ 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 "fetches user featured collection" do ap_id = "https://example.com/users/lain" 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..91b33b9e2 --- /dev/null +++ b/test/pleroma/web/activity_pub/object_validators/user_validator_test.exs @@ -0,0 +1,65 @@ +# 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 + + 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/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!() From 20354e0aeeee0d31a3ffc0a3cb741b9b2bbd5772 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 21:45:14 +0400 Subject: [PATCH 27/32] fix(fetcher): require fetched documents to have the fetched id The fetcher accepted any document whose id was on the same host as the fetch URL. Anyone who can place a JSON file on a host, for example through an upload, could pass it off as another object or actor from that host. The id of a fetched document must now be the URL it came from (after redirects), ignoring a trailing slash, the fragment, the host case and differences that our URL encoding removes. If it differs, as for Mastodon's /@user/123 display URLs, we refetch once from that id, which must be on the same host. The refetch must not redirect and must match exactly. Test mocks that served a document under a different URL now serve it from its own id. Based on Akkoma 8684964c5 and 940792f8b. --- changelog.d/fetch-id-containment.security | 1 + lib/pleroma/object/containment.ex | 28 ++++ lib/pleroma/object/fetcher.ex | 35 +++- .../tesla_mock/relay@mastdon.example.org.json | 6 +- test/pleroma/object/containment_test.exs | 34 ++++ test/pleroma/object/fetcher_test.exs | 154 +++++++++++++++++- .../web/activity_pub/activity_pub_test.exs | 6 +- test/support/http_request_mock.ex | 10 ++ 8 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 changelog.d/fetch-id-containment.security 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/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index b50d560d1..fe42bfa69 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -108,6 +108,34 @@ 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) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 084d44d5e..8a6f5561f 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -140,17 +140,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 +190,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 +216,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 +230,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/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/object/containment_test.exs b/test/pleroma/object/containment_test.exs index 5560dfce5..1319064f2 100644 --- a/test/pleroma/object/containment_test.exs +++ b/test/pleroma/object/containment_test.exs @@ -127,4 +127,38 @@ defmodule Pleroma.Object.ContainmentTest do :ok = Containment.contain_child(data) 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..411f69bea 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() } @@ -642,7 +644,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 +713,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 +736,131 @@ 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 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/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 125a2b4f9..7294119b8 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -541,7 +541,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 %{ @@ -2058,7 +2058,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() ) @@ -2070,7 +2070,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/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{ From f1337d81593315c90e79840a21b1372e9f794d57 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 21:46:14 +0400 Subject: [PATCH 28/32] fix(fetcher): keep id and author fixed when refetching objects refetch_object (used for poll updates) accepted whatever the remote served. A hostile server could swap in another object or move the post to another author. The refetch now goes to the stored id without redirects, and the returned id and actor must equal the stored ones. Based on Akkoma 9061d148b and fee57eb37. --- changelog.d/refetch-containment.security | 1 + lib/pleroma/object/fetcher.ex | 12 +++++- test/pleroma/object/fetcher_test.exs | 54 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 changelog.d/refetch-containment.security 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/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 8a6f5561f..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 diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index 411f69bea..c9c4890cd 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -465,6 +465,60 @@ 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 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 From f22caeff530b2b218b9e33a216c4828ef4fd2eac Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 21:46:45 +0400 Subject: [PATCH 29/32] fix(user): update the known user when fetched from a display URL A fetch from a display URL (e.g. https://host/@user) returns the actor with its canonical id. make_user_from_ap_id looked the user up by the fetch URL, did not find it and tried to insert a duplicate. Look the user up by the fetched id. Based on Akkoma 48b3a3579. --- changelog.d/user-fetch-display-url.fix | 1 + lib/pleroma/web/activity_pub/activity_pub.ex | 5 ++-- .../web/activity_pub/activity_pub_test.exs | 27 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 changelog.d/user-fetch-display-url.fix 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/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 18cb2cadc..b997e7831 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -2055,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/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 7294119b8..c118cd8f1 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -269,6 +269,33 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do 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" From c81b1261dc930dbec5d6e9f6b8f569db578a2762 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 22:02:31 +0400 Subject: [PATCH 30/32] fix(federation): treat empty actor fields as missing An empty inbox, collection or key id was rejected as being on another host. Treat empty strings like null and only check fields with a value. --- .../activity_pub/object_validators/user_validator.ex | 5 +++-- .../object_validators/user_validator_test.exs | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/user_validator.ex b/lib/pleroma/web/activity_pub/object_validators/user_validator.ex index 183be4940..a368ccc16 100644 --- a/lib/pleroma/web/activity_pub/object_validators/user_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/user_validator.ex @@ -10,7 +10,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidator do 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. + collections. Optional fields are only checked when present; null and + empty strings count as missing. Based on Akkoma's UserValidator. """ @@ -38,7 +39,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidator do def validate(_data, _meta), do: {:error, "Actor has no id"} - defp same_origin?(_id, nil), do: true + 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 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 index 91b33b9e2..fbc89462e 100644 --- a/test/pleroma/web/activity_pub/object_validators/user_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/user_validator_test.exs @@ -32,6 +32,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidatorTest do 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") From 90e330e5b37578cc128f79d4dc6f05d9551a5696 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 22:03:13 +0400 Subject: [PATCH 31/32] fix(containment): return nil for actor shapes without an id Containment.get_actor raised on remote shapes it did not know, such as an attributedTo object without an id. This crashed object fetches and poll refetches. Return nil, so containment rejects the object. --- changelog.d/get-actor-crash.fix | 1 + lib/pleroma/object/containment.ex | 3 ++ test/pleroma/object/containment_test.exs | 18 ++++++++++++ test/pleroma/object/fetcher_test.exs | 37 ++++++++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 changelog.d/get-actor-crash.fix 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/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index fe42bfa69..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 diff --git a/test/pleroma/object/containment_test.exs b/test/pleroma/object/containment_test.exs index 1319064f2..2fdc83605 100644 --- a/test/pleroma/object/containment_test.exs +++ b/test/pleroma/object/containment_test.exs @@ -128,6 +128,24 @@ defmodule Pleroma.Object.ContainmentTest do 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 = diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index c9c4890cd..61a6fb517 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -488,6 +488,24 @@ defmodule Pleroma.Object.FetcherTest do 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) @@ -909,6 +927,25 @@ defmodule Pleroma.Object.FetcherTest do ) 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") From 3890fbc1277df31f51f705daebdb706d71f4184b Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 28 Oct 2026 11:38:49 +0400 Subject: [PATCH 32/32] feat(api): add the Mastodon quote entity to statuses Mastodon 4.4+ clients read quotes from a top-level quote object with state and quoted_status; Pleroma only had pleroma.quote, so they did not show quote posts. Quoted statuses get a shallow quote with only quoted_status_id, as quotes are rendered one level deep. Inspired by Akkoma 5b7209980, which can not use the shallow form because Akkoma already had a top-level quote status. --- changelog.d/mastodon-quote-entity.add | 1 + lib/pleroma/web/api_spec/schemas/status.ex | 23 +++++++ .../web/mastodon_api/views/status_view.ex | 25 +++++++- .../mastodon_api/views/status_view_test.exs | 60 ++++++++++++++++++- 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 changelog.d/mastodon-quote-entity.add 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/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/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 6a5d627a5..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), @@ -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/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 763eb1599..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)