fix(federation): enforce the signature policy on incoming requests

HTTPSignaturePlug checks the policy before the cryptographic check. A
signed GET that fails it counts as unsigned; the inbox answers 401 to a
POST that fails it instead of queueing a failed-signature retry, which
only re-checks the signature. signature_policy: :log only logs.

Based on Akkoma's HTTP signature hardening.
This commit is contained in:
Lain Soykaf 2026-10-27 21:46:40 +04:00
commit e5481b4c28
No known key found for this signature in database
7 changed files with 329 additions and 59 deletions

View file

@ -0,0 +1 @@
Reject incoming HTTP signatures that do not sign the date, are older than 2 hours (or more than 1 hour in the future), or do not sign the digest of a request body, so captured requests cannot be replayed. Set `config :pleroma, :activitypub, signature_policy: :log` to only log these

View file

@ -386,18 +386,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
def inbox(%{assigns: %{valid_signature: false}} = conn, params) do
if unsupported_http_message_signature?(conn) do
reject_unsupported_http_message_signature(conn)
else
Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,
params: params,
query_string: conn.query_string
})
cond do
unsupported_http_message_signature?(conn) ->
reject_unsupported_http_message_signature(conn)
json(conn, "ok")
reason = conn.assigns[:signature_error] ->
conn
|> put_status(:unauthorized)
|> json("error, invalid HTTP signature (#{reason})")
true ->
Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,
params: params,
query_string: conn.query_string
})
json(conn, "ok")
end
end

View file

@ -45,20 +45,52 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
defp maybe_assign_valid_signature(conn) do
if has_signature_header?(conn) do
# we replace the digest header with the one we computed in DigestPlug
conn =
case conn do
%{assigns: %{digest: digest}} = conn -> put_req_header(conn, "digest", digest)
conn -> conn
end
conn = put_computed_digest(conn)
assign(conn, :valid_signature, Signature.validate_signature(conn))
case check_signature_policy(conn) do
:ok ->
assign(conn, :valid_signature, Signature.validate_signature(conn))
{:error, reason} ->
reject_signature(conn, reason)
end
else
Logger.debug("No signature header!")
conn
end
end
# we replace the digest header with the one we computed in DigestPlug
defp put_computed_digest(%{assigns: %{digest: digest}} = conn),
do: put_req_header(conn, "digest", digest)
defp put_computed_digest(conn), do: conn
defp check_signature_policy(conn) do
with {:error, reason} <- Signature.check_signature_policy(conn) do
if Pleroma.Config.get([:activitypub, :signature_policy], :enforce) == :log do
Logger.warning(
"HTTP signature policy violation (#{reason}) for #{conn.method} #{conn.request_path}, allowed by config"
)
:ok
else
{:error, reason}
end
end
end
# The request is treated as badly signed. The reason is kept so the inbox
# can refuse it instead of queueing a failed-signature retry, which only
# checks the cryptographic signature.
defp reject_signature(conn, reason) do
Logger.debug("Rejecting HTTP signature: #{reason}")
conn
|> assign(:valid_signature, false)
|> assign(:signature_error, reason)
end
defp maybe_assign_actor_id(%{assigns: %{valid_signature: true}} = conn) do
adapter = Application.get_env(:http_signatures, :adapter)

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
alias Pleroma.Activity
alias Pleroma.Delivery
alias Pleroma.Object
alias Pleroma.Signature
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
@ -49,6 +50,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
|> put_req_header("signature", "keyId=\"#{actor_id}#main-key\"")
end
defp put_fake_signature(conn, actor_id, date \\ Signature.signed_date()) do
conn
|> put_req_header("date", date)
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature",
"keyId=\"#{actor_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
end
defp expect_signature_retry_from(%User{} = signer) do
signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured")
@ -792,12 +803,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
}
}
Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end)
Mox.stub(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end)
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("content-digest", "sha-256=:fake-digest:")
|> put_req_header(
"signature-input",
@ -832,12 +843,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
legacy_signature =
"keyId=\"https://activitypubbot.example/user/ok#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end)
Mox.stub(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end)
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header("signature", "sig1=:fake-signature:")
|> Map.update!(:req_headers, &[{"signature", legacy_signature} | &1])
@ -871,7 +882,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature-input",
@ -881,12 +892,44 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
"signature",
"keyId=\"https://activitypubbot.example/user/ok#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
|> post("/inbox", data)
|> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200)
assert [_] = all_enqueued(worker: SignatureRetryWorker)
end
test "refuses a replayed signed POST instead of queueing a retry", %{conn: conn} do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!()
stale = NaiveDateTime.add(NaiveDateTime.utc_now(), -3 * 3600)
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_fake_signature(data["actor"], Signature.signed_date(stale))
|> post("/inbox", Jason.encode!(data))
assert "error, invalid HTTP signature (date_too_old)" == json_response(conn, 401)
assert [] == all_enqueued(worker: SignatureRetryWorker)
assert [] == all_enqueued(worker: ReceiverWorker)
end
test "refuses a signed POST that does not cover the digest", %{conn: conn} do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!()
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", Signature.signed_date())
|> put_req_header(
"signature",
"keyId=\"#{data["actor"]}#main-key\",headers=\"(request-target) host date\",signature=\"fake\""
)
|> post("/inbox", Jason.encode!(data))
assert "error, invalid HTTP signature (digest_not_signed)" == json_response(conn, 401)
assert [] == all_enqueued(worker: SignatureRetryWorker)
end
test "does not create a forged post after failed signature retry", %{conn: conn} do
alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
@ -918,8 +961,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
|> put_fake_signature(alice.ap_id)
|> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200)
@ -950,8 +993,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
|> put_fake_signature(alice.ap_id)
|> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200)
@ -982,8 +1025,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn
|> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"")
|> post("/inbox", data)
|> put_fake_signature(alice.ap_id)
|> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200)
@ -1025,13 +1068,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature",
"keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
|> post("/inbox", data)
|> post("/inbox", Jason.encode!(data))
assert conn.assigns.valid_signature == false
assert "ok" == json_response(conn, 200)
@ -1063,13 +1106,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn =
conn
|> put_req_header("content-type", "application/activity+json")
|> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT")
|> put_req_header("date", Signature.signed_date())
|> put_req_header("digest", "SHA-256=fake-digest")
|> put_req_header(
"signature",
"keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\""
)
|> post("/inbox", data)
|> post("/inbox", Jason.encode!(data))
assert conn.assigns.valid_signature == false
assert "ok" == json_response(conn, 200)

View file

@ -26,7 +26,11 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugMRFTest do
|> expect(:validate_conn, fn _ -> true end)
build_conn(:get, "/doesntmattter", %{"actor" => actor})
|> put_req_header("signature", "keyId=\"#{actor}#main-key")
|> put_req_header("date", Pleroma.Signature.signed_date())
|> put_req_header(
"signature",
"keyId=\"#{actor}#main-key\",headers=\"(request-target) host date\",signature=\"fake\""
)
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
end

View file

@ -0,0 +1,119 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.HTTPSignaturePlugPolicyTest do
# Not async: changes global config and uses global HTTP mocks.
use Pleroma.Web.ConnCase
alias Pleroma.Keys
alias Pleroma.Object.Fetcher
alias Pleroma.Repo
alias Pleroma.StubbedHTTPSignaturesMock, as: HTTPSignaturesMock
alias Pleroma.User
alias Pleroma.Web.ActivityPub.InternalFetchActor
alias Pleroma.Web.ActivityPub.Publisher
alias Pleroma.Web.Plugs.DigestPlug
alias Pleroma.Web.Plugs.HTTPSignaturePlug
import ExUnit.CaptureLog
import Mox
import Phoenix.Controller, only: [put_format: 2]
import Pleroma.Factory
import Plug.Conn
defp incoming_conn(method, url, headers, body \\ "") do
%URI{host: host, path: path} = URI.parse(url)
headers = Enum.map(headers, fn {name, value} -> {String.downcase(name), value} end)
Plug.Test.conn(method, path, body)
|> Map.put(:req_headers, [{"host", host} | headers])
|> put_format("activity+json")
end
# Local users have no public_key column set; publish it like a remote copy would.
defp store_public_key(%User{} = user) do
{:ok, _, public_key} = Keys.keys_from_pem(user.keys)
entry = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)
user
|> Ecto.Changeset.change(public_key: :public_key.pem_encode([entry]))
|> Repo.update!()
|> User.set_cache()
end
defp capture_request(method) do
test_pid = self()
Tesla.Mock.mock_global(fn %{method: ^method, headers: headers, body: body} = env ->
send(test_pid, {:request, headers, body})
%{env | status: 200, body: ""}
end)
end
test "accepts deliveries signed by Pleroma's publisher" do
stub_with(Pleroma.Web.ActivityPub.TransmogrifierMock, Pleroma.Web.ActivityPub.Transmogrifier)
capture_request(:post)
activity = insert(:note_activity)
store_public_key(User.get_by_ap_id(activity.data["actor"]))
inbox = "https://remote.example/inbox"
{:ok, _} =
%{inbox: inbox, activity_id: activity.id}
|> Publisher.prepare_one()
|> Publisher.publish_one()
assert_received {:request, headers, body}
{:ok, _, conn} =
incoming_conn(:post, inbox, [{"content-length", "#{byte_size(body)}"} | headers], body)
|> DigestPlug.read_body([])
conn = HTTPSignaturePlug.call(conn, %{})
assert conn.assigns.valid_signature == true
refute conn.halted
end
test "accepts object fetches signed by Pleroma's fetcher" do
clear_config([:activitypub, :sign_object_fetches], true)
capture_request(:get)
store_public_key(InternalFetchActor.get_actor())
url = "https://remote.example/objects/1"
Fetcher.fetch_and_contain_remote_object_from_id(url)
assert_received {:request, headers, _body}
conn =
incoming_conn(:get, url, headers)
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
end
test "only logs policy violations when the policy is set to :log" do
clear_config([:activitypub, :signature_policy], :log)
HTTPSignaturesMock
|> expect(:validate_conn, fn _ -> true end)
log =
capture_log(fn ->
conn =
incoming_conn(:post, "https://pleroma.example/inbox", [
{"date", Pleroma.Signature.signed_date()},
{"signature",
"keyId=\"https://remote.example/users/a#main-key\",headers=\"(request-target) host date\",signature=\"fake\""}
])
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
refute conn.halted
end)
assert log =~ "digest_not_signed"
end
end

View file

@ -13,6 +13,15 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
import Phoenix.Controller, only: [put_format: 2]
import Plug.Conn
defp put_signature(conn, actor, signed_headers \\ "(request-target) host date") do
conn
|> put_req_header("date", Pleroma.Signature.signed_date())
|> put_req_header(
"signature",
"keyId=\"#{actor}#main-key\",headers=\"#{signed_headers}\",signature=\"fake\""
)
end
test "it calls HTTPSignatures to check validity if the actor signed it" do
params = %{"actor" => "http://mastodon.example.org/users/admin"}
conn = build_conn(:get, "/doesntmattter", params)
@ -22,10 +31,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
@ -51,10 +57,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
orig_conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false
@ -71,10 +74,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
orig_conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
@ -108,10 +108,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
conn
|> Map.put(:remote_ip, {192, 168, 0, 1})
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> HTTPSignaturePlug.call(%{})
assert conn.remote_ip == {192, 168, 0, 1}
@ -131,10 +128,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
build_conn(:get, "/doesntmattter", %{"actor" => "http://mastodon.example.org/users/admin"})
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_signature("http://mastodon.example.org/users/admin")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
@ -152,14 +146,84 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
build_conn(:get, "/doesntmattter", %{"actor" => "http://allowed.example.org/users/admin"})
|> put_req_header(
"signature",
"keyId=\"http://allowed.example.org/users/admin#main-key"
)
|> put_signature("http://allowed.example.org/users/admin")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == false
end
describe "signature policy" do
test "treats a signed GET with a stale date as unsigned" do
HTTPSignaturesMock
|> expect(:validate_conn, 0, fn _ -> true end)
stale = NaiveDateTime.add(NaiveDateTime.utc_now(), -3 * 3600)
conn =
build_conn(:get, "/doesntmattter")
|> put_signature("http://mastodon.example.org/users/admin")
|> put_req_header("date", Pleroma.Signature.signed_date(stale))
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false
assert conn.assigns.signature_error == :date_too_old
refute conn.halted
end
test "marks a signed POST that does not cover the digest as invalid" do
HTTPSignaturesMock
|> expect(:validate_conn, 0, fn _ -> true end)
conn =
build_conn(:post, "/inbox")
|> put_signature("http://mastodon.example.org/users/admin")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false
assert conn.assigns.signature_error == :digest_not_signed
refute conn.halted
end
test "uses the digest computed from the body for signed POSTs" do
HTTPSignaturesMock
|> expect(:validate_conn, fn conn ->
get_req_header(conn, "digest") == ["SHA-256=computed"]
end)
conn =
build_conn(:post, "/inbox")
|> put_signature(
"http://mastodon.example.org/users/admin",
"(request-target) host date digest"
)
|> put_req_header("digest", "SHA-256=claimed")
|> assign(:digest, "SHA-256=computed")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
end
test "marks a signed POST whose body was not hashed as invalid" do
HTTPSignaturesMock
|> expect(:validate_conn, 0, fn _ -> true end)
conn =
build_conn(:post, "/inbox")
|> put_signature(
"http://mastodon.example.org/users/admin",
"(request-target) host date digest"
)
|> put_req_header("digest", "SHA-256=claimed")
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false
assert conn.assigns.signature_error == :body_not_hashed
end
end
end