From da7e75ca731dc407e19042619c90ed0868dbdf66 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 26 Oct 2026 12:46:59 +0400 Subject: [PATCH] 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