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.
This commit is contained in:
Lain Soykaf 2026-10-26 12:46:59 +04:00
commit da7e75ca73
No known key found for this signature in database
3 changed files with 147 additions and 0 deletions

View file

@ -0,0 +1 @@
Check OAuth token scopes on the ActivityPub C2S endpoints; posting to the outbox with a token now needs the `write` scope

View file

@ -22,8 +22,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
alias Pleroma.Web.ControllerHelper alias Pleroma.Web.ControllerHelper
alias Pleroma.Web.Endpoint alias Pleroma.Web.Endpoint
alias Pleroma.Web.Federator alias Pleroma.Web.Federator
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug
alias Pleroma.Web.Plugs.FederatingPlug alias Pleroma.Web.Plugs.FederatingPlug
alias Pleroma.Web.Plugs.OAuthScopesPlug
require Logger require Logger
@ -46,6 +48,24 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
when action in [:read_inbox, :update_outbox, :whoami, :upload_media] 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(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:upload_media])
plug( plug(
@ -57,6 +77,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
plug(:log_inbox_metadata when action in [:inbox]) plug(:log_inbox_metadata when action in [:inbox])
plug(:relay_active? when action in [:relay]) 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 defp relay_active?(conn, _) do
if Pleroma.Config.get([:instance, :allow_relay]) do if Pleroma.Config.get([:instance, :allow_relay]) do
conn conn

View file

@ -3104,4 +3104,125 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
obj_id in object_ids obj_id in object_ids
end) end)
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 end