From e676ec350713d766b653f60f906fceec34c2c6a1 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 29 May 2026 18:35:15 +0400 Subject: [PATCH 1/2] Reject unsupported HTTP Message Signatures --- .../activitypub-bot-rfc9421-fallback.fix | 1 + .../activity_pub/activity_pub_controller.ex | 33 ++++++-- .../activity_pub_controller_test.exs | 77 +++++++++++++++++++ 3 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 changelog.d/activitypub-bot-rfc9421-fallback.fix diff --git a/changelog.d/activitypub-bot-rfc9421-fallback.fix b/changelog.d/activitypub-bot-rfc9421-fallback.fix new file mode 100644 index 000000000..22642fee1 --- /dev/null +++ b/changelog.d/activitypub-bot-rfc9421-fallback.fix @@ -0,0 +1 @@ +Reject unsupported RFC 9421 inbox signatures instead of queueing a doomed failed-signature retry, allowing clients to fall back to legacy HTTP Signatures. diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 3d1875599..dd644c132 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -353,15 +353,21 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end def inbox(%{assigns: %{valid_signature: false}} = conn, params) do - 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 - }) + if unsupported_http_message_signature?(conn) do + conn + |> put_status(:unauthorized) + |> json("error, unsupported HTTP Message Signature") + 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 + }) - json(conn, "ok") + json(conn, "ok") + end end # POST /relay/inbox -or- POST /internal/fetch/inbox @@ -381,6 +387,17 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json("error, missing HTTP Signature") end + defp unsupported_http_message_signature?(conn) do + Plug.Conn.get_req_header(conn, "signature-input") != [] and + not cavage_signature?(conn) + end + + defp cavage_signature?(conn) do + conn + |> Plug.Conn.get_req_header("signature") + |> Enum.any?(&Regex.match?(~r/(^|,)\s*keyId\s*=/, &1)) + end + defp post_inbox_relayed_create(conn, params) do Logger.debug( "Signature missing or not from author, relayed Create message, fetching object from source" 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 68e39b3a0..e9a777758 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -757,6 +757,83 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert Activity.get_by_ap_id(data["id"]) end + test "rejects unsupported HTTP Message Signatures so clients can fall back", %{conn: conn} do + data = %{ + "type" => "Create", + "actor" => "https://activitypubbot.example/user/ok", + "id" => "https://activitypubbot.example/activities/rfc9421-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://activitypubbot.example/objects/rfc9421-note", + "actor" => "https://activitypubbot.example/user/ok", + "attributedTo" => "https://activitypubbot.example/user/ok", + "content" => "hello from activitypub-bot", + "published" => "2026-05-29T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + Mox.expect(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("content-digest", "sha-256=:fake-digest:") + |> put_req_header( + "signature-input", + "sig1=(\"@method\" \"@target-uri\" \"date\" \"content-type\" \"content-digest\");keyid=\"https://activitypubbot.example/user/ok/publickey\";alg=\"rsa-v1_5-sha256\";created=1780061611" + ) + |> put_req_header("signature", "sig1=:fake-signature:") + |> post("/inbox", data) + + assert "error, unsupported HTTP Message Signature" == json_response(conn, 401) + assert [] == all_enqueued(worker: SignatureRetryWorker) + end + + test "keeps failed-signature retry for legacy signatures with Signature-Input", %{conn: conn} do + data = %{ + "type" => "Create", + "actor" => "https://activitypubbot.example/user/ok", + "id" => "https://activitypubbot.example/activities/legacy-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://activitypubbot.example/objects/legacy-note", + "actor" => "https://activitypubbot.example/user/ok", + "attributedTo" => "https://activitypubbot.example/user/ok", + "content" => "hello from activitypub-bot", + "published" => "2026-05-29T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + Mox.expect(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("digest", "SHA-256=fake-digest") + |> put_req_header( + "signature-input", + "sig1=(\"@method\" \"@target-uri\" \"date\" \"content-type\" \"digest\");keyid=\"https://activitypubbot.example/user/ok/publickey\";alg=\"rsa-v1_5-sha256\";created=1780061611" + ) + |> put_req_header( + "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) + + assert "ok" == json_response(conn, 200) + 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") From 9df50eea82e4141bca673750448c5e795d31fa49 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 26 Jul 2026 12:26:35 +0400 Subject: [PATCH 2/2] fix: reject duplicate Signature headers --- .../activitypub-bot-rfc9421-fallback.fix | 2 +- .../activity_pub/activity_pub_controller.ex | 42 ++++++++---- .../activity_pub_controller_test.exs | 67 +++++++++++++++++++ 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/changelog.d/activitypub-bot-rfc9421-fallback.fix b/changelog.d/activitypub-bot-rfc9421-fallback.fix index 22642fee1..a04940bfe 100644 --- a/changelog.d/activitypub-bot-rfc9421-fallback.fix +++ b/changelog.d/activitypub-bot-rfc9421-fallback.fix @@ -1 +1 @@ -Reject unsupported RFC 9421 inbox signatures instead of queueing a doomed failed-signature retry, allowing clients to fall back to legacy HTTP Signatures. +Reject unsupported or ambiguous inbox signatures instead of queueing a doomed failed-signature retry, allowing clients to fall back to legacy HTTP Signatures. diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index dd644c132..38b8b8bfc 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -309,7 +309,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do } = conn, %{"nickname" => nickname} = params ) do - with {:recipient_exists, %User{} = recipient} <- + with false <- unsupported_http_message_signature?(conn), + {:recipient_exists, %User{} = recipient} <- {:recipient_exists, User.get_cached_by_nickname(nickname)}, {:sender_exists, {:ok, %User{} = actor}} <- {:sender_exists, User.get_or_fetch_by_ap_id(params["actor"])}, @@ -320,6 +321,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do Federator.incoming_ap_doc(params) json(conn, "ok") else + true -> + reject_unsupported_http_message_signature(conn) + {:recipient_exists, _} -> conn |> put_status(:not_found) @@ -348,15 +352,17 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end def inbox(%{assigns: %{valid_signature: true, valid_host_header: true}} = conn, params) do - Federator.incoming_ap_doc(params) - json(conn, "ok") + if unsupported_http_message_signature?(conn) do + reject_unsupported_http_message_signature(conn) + else + Federator.incoming_ap_doc(params) + json(conn, "ok") + end end def inbox(%{assigns: %{valid_signature: false}} = conn, params) do if unsupported_http_message_signature?(conn) do - conn - |> put_status(:unauthorized) - |> json("error, unsupported HTTP Message Signature") + reject_unsupported_http_message_signature(conn) else Federator.incoming_failed_signature_ap_doc(%{ method: conn.method, @@ -387,15 +393,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json("error, missing HTTP Signature") end - defp unsupported_http_message_signature?(conn) do - Plug.Conn.get_req_header(conn, "signature-input") != [] and - not cavage_signature?(conn) + defp reject_unsupported_http_message_signature(conn) do + conn + |> put_status(:unauthorized) + |> json("error, unsupported HTTP Message Signature") end - defp cavage_signature?(conn) do - conn - |> Plug.Conn.get_req_header("signature") - |> Enum.any?(&Regex.match?(~r/(^|,)\s*keyId\s*=/, &1)) + defp unsupported_http_message_signature?(conn) do + signature_headers = Plug.Conn.get_req_header(conn, "signature") + + multiple_signature_headers?(signature_headers) or + (Plug.Conn.get_req_header(conn, "signature-input") != [] and + not cavage_signature?(signature_headers)) + end + + defp multiple_signature_headers?([_, _ | _]), do: true + defp multiple_signature_headers?(_), do: false + + defp cavage_signature?(signature_headers) do + Enum.any?(signature_headers, &Regex.match?(~r/(^|,)\s*keyId\s*=/, &1)) end defp post_inbox_relayed_create(conn, params) do diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index e9a777758..0de9618d4 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -729,6 +729,22 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert Activity.get_by_ap_id(data["id"]) end + test "rejects duplicate Signature headers before processing signed inbox requests", %{ + conn: conn + } do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + + conn = + conn + |> assign_valid_signature_for_actor(data["actor"]) + |> put_req_header("content-type", "application/activity+json") + |> Map.update!(:req_headers, &[{"signature", "sig1=:fake-signature:"} | &1]) + |> post("/inbox", data) + + assert "error, unsupported HTTP Message Signature" == json_response(conn, 401) + assert [] == all_enqueued(worker: ReceiverWorker) + end + test "it inserts an incoming activity into the database" <> "even if we can't fetch the user but have it in our db", %{conn: conn} do @@ -794,6 +810,43 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert [] == all_enqueued(worker: SignatureRetryWorker) end + test "rejects duplicate Signature headers so clients can fall back", %{conn: conn} do + data = %{ + "type" => "Create", + "actor" => "https://activitypubbot.example/user/ok", + "id" => "https://activitypubbot.example/activities/duplicate-signature-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://activitypubbot.example/objects/duplicate-signature-note", + "actor" => "https://activitypubbot.example/user/ok", + "attributedTo" => "https://activitypubbot.example/user/ok", + "content" => "hello from activitypub-bot", + "published" => "2026-05-29T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + 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) + + 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("digest", "SHA-256=fake-digest") + |> put_req_header("signature", "sig1=:fake-signature:") + |> Map.update!(:req_headers, &[{"signature", legacy_signature} | &1]) + |> post("/inbox", data) + + assert "error, unsupported HTTP Message Signature" == json_response(conn, 401) + assert [] == all_enqueued(worker: SignatureRetryWorker) + end + test "keeps failed-signature retry for legacy signatures with Signature-Input", %{conn: conn} do data = %{ "type" => "Create", @@ -1397,6 +1450,20 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert Activity.get_by_ap_id(data["id"]) end + test "rejects duplicate Signature headers", %{conn: conn, data: data} do + user = insert(:user) + + conn = + conn + |> assign_valid_signature_for_actor(data["actor"]) + |> put_req_header("content-type", "application/activity+json") + |> Map.update!(:req_headers, &[{"signature", "sig1=:fake-signature:"} | &1]) + |> post("/users/#{user.nickname}/inbox", data) + + assert "error, unsupported HTTP Message Signature" == json_response(conn, 401) + assert [] == all_enqueued(worker: ReceiverWorker) + end + test "it accepts messages with to as string instead of array", %{conn: conn, data: data} do user = insert(:user)