From f57e97bf18a24799bb8924218838b52ea51cec8c Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 21:46:40 +0400 Subject: [PATCH 1/3] feat(signature): add a freshness and coverage policy for incoming signatures Signature.check_signature_policy/2 requires date or (created) to be signed and within signature_max_age (2 h) / signature_max_clock_skew (1 h), and requires a signed, hashed digest for requests with a body. Based on Akkoma's http_signatures fork (required headers, max_sig_age) and Mastodon's SignedRequest (clock skew margin). --- config/config.exs | 3 + config/description.exs | 21 +++++ docs/configuration/cheatsheet.md | 3 + lib/pleroma/signature.ex | 78 +++++++++++++++++++ test/pleroma/signature_test.exs | 129 +++++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+) diff --git a/config/config.exs b/config/config.exs index 1b8baed1f..617b4b8e5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -373,6 +373,9 @@ config :pleroma, :activitypub, note_replies_output_limit: 5, sign_object_fetches: true, authorized_fetch_mode: false, + signature_policy: :enforce, + signature_max_age: 7200, + signature_max_clock_skew: 3600, client_api_enabled: false, anonymize_reporter: false, anonymize_reporter_local_nickname: "" diff --git a/config/description.exs b/config/description.exs index f435628aa..1b065fe6c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1788,6 +1788,27 @@ config :pleroma, :config_description, [ description: "List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this)" }, + %{ + key: :signature_policy, + type: {:dropdown, :atom}, + description: + "What to do with incoming HTTP signatures that do not sign the date (or `(created)`), are outside the allowed time window, or do not sign the digest of a request body. `:enforce` treats them as invalid, `:log` only logs them", + suggestions: [:enforce, :log] + }, + %{ + key: :signature_max_age, + type: :integer, + description: + "How old, in seconds, the signed date of an incoming HTTP signature can be. Default: 7200 (2 hours)", + suggestions: [7200] + }, + %{ + key: :signature_max_clock_skew, + type: :integer, + description: + "How far, in seconds, the signed date of an incoming HTTP signature can be in the future. Default: 3600 (1 hour). A `(created)` time in the future is always rejected", + suggestions: [3600] + }, %{ key: :note_replies_output_limit, type: :integer, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 4865ff0aa..d49aa51f7 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -286,6 +286,9 @@ Notes: * `sign_object_fetches`: Sign object fetches with HTTP signatures * `authorized_fetch_mode`: Require HTTP signatures for AP fetches * `authorized_fetch_mode_exceptions`: List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this) +* `signature_policy`: What to do with incoming HTTP signatures that do not sign the `date` header (or `(created)`), whose signed date is outside the window below, or that do not sign the `digest` header of a request with a body (or whose body Pleroma did not hash). `:enforce` (default) treats them as invalid, `:log` only logs a warning. +* `signature_max_age`: How old, in seconds, the signed date of an incoming HTTP signature can be. Default: `7200` (2 hours). +* `signature_max_clock_skew`: How far, in seconds, the signed `date` of an incoming HTTP signature can be in the future. Default: `3600` (1 hour). A `(created)` time in the future is always rejected. ## Pleroma.User diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 382cf9db3..e85c2aa5e 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -95,6 +95,84 @@ defmodule Pleroma.Signature do Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") end + @doc """ + Checks the parts of an incoming signature that the cryptographic check does + not cover, so that a captured request cannot be replayed later or with a + different body. + + The signature must cover `date` or `(created)`, and the signed time must be + within the configured window. Requests other than GET and HEAD must also + sign `digest`, and DigestPlug must have hashed their body. Based on Akkoma's http_signatures fork and Mastodon's + SignedRequest. + """ + @spec check_signature_policy(Plug.Conn.t(), DateTime.t()) :: :ok | {:error, atom()} + def check_signature_policy(%Plug.Conn{} = conn, now \\ DateTime.utc_now()) do + signature = HTTPSignatures.signature_for_conn(conn) + signed = Map.get(signature, "headers", []) + + cond do + "date" not in signed and "(created)" not in signed -> + {:error, :date_not_signed} + + conn.method not in ["GET", "HEAD"] and "digest" not in signed -> + {:error, :digest_not_signed} + + # The plug puts the digest computed by DigestPlug into the header. With + # no computed digest (e.g. multipart), nothing checked the body. + conn.method not in ["GET", "HEAD"] and is_nil(conn.assigns[:digest]) -> + {:error, :body_not_hashed} + + true -> + with :ok <- check_signed_date(conn, signed, now) do + check_signed_created(signature, signed, now) + end + end + end + + defp check_signed_date(conn, signed, now) do + if "date" in signed do + # Exactly one header: a second one could make this check and the + # signature check see different dates. + with [date] <- Plug.Conn.get_req_header(conn, "date"), + {:ok, %DateTime{} = date} <- Timex.parse(date, "{RFC1123}") do + check_time_window(date, now) + else + _ -> {:error, :invalid_date} + end + else + :ok + end + end + + defp check_signed_created(signature, signed, now) do + if "(created)" in signed do + with created when is_binary(created) <- signature["created"], + {created, ""} <- Integer.parse(created), + {:ok, created} <- DateTime.from_unix(created) do + check_time_window(created, now) + else + _ -> {:error, :invalid_date} + end + else + :ok + end + end + + defp check_time_window(signed_at, now) do + age = DateTime.diff(now, signed_at) + + cond do + age > Pleroma.Config.get([:activitypub, :signature_max_age], 7200) -> + {:error, :date_too_old} + + -age > Pleroma.Config.get([:activitypub, :signature_max_clock_skew], 3600) -> + {:error, :date_in_future} + + true -> + :ok + end + end + @spec validate_signature(Plug.Conn.t(), String.t()) :: boolean() def validate_signature(%Plug.Conn{} = conn, request_target) do # Newer drafts for HTTP signatures now use @request-target instead of the diff --git a/test/pleroma/signature_test.exs b/test/pleroma/signature_test.exs index 0c7c4c840..18a2a1ddc 100644 --- a/test/pleroma/signature_test.exs +++ b/test/pleroma/signature_test.exs @@ -116,6 +116,135 @@ defmodule Pleroma.SignatureTest do end end + describe "check_signature_policy/2" do + @now ~U[2026-10-27 12:00:00Z] + + defp policy_conn(method, signed_headers, extra_headers, extra_params \\ "") do + signature = + "keyId=\"https://remote.example/users/a#main-key\",headers=\"#{signed_headers}\"" <> + extra_params <> ",signature=\"fake\"" + + %Plug.Conn{ + method: method, + req_headers: [{"signature", signature}, {"host", "pleroma.example"} | extra_headers], + assigns: %{digest: "SHA-256=abc"} + } + end + + defp date_header(seconds_ago) do + date = DateTime.add(@now, -seconds_ago) |> DateTime.to_naive() + {"date", Signature.signed_date(date)} + end + + test "accepts a fresh signed GET (Pleroma, Mastodon, Misskey style)" do + for headers <- ["(request-target) host date", "(request-target) host date accept"] do + conn = policy_conn("GET", headers, [date_header(10)]) + assert Signature.check_signature_policy(conn, @now) == :ok + end + end + + test "accepts a fresh signed POST covering the digest" do + conn = + policy_conn("POST", "(request-target) host content-length digest date", [ + date_header(10), + {"digest", "SHA-256=abc"} + ]) + + assert Signature.check_signature_policy(conn, @now) == :ok + end + + test "accepts (created) instead of date (GoToSocial style)" do + created = DateTime.to_unix(@now) - 10 + + conn = + policy_conn( + "POST", + "(request-target) (created) host digest", + [{"digest", "SHA-256=abc"}], + ",algorithm=\"hs2019\",created=#{created}" + ) + + assert Signature.check_signature_policy(conn, @now) == :ok + end + + test "rejects signatures that cover neither date nor (created)" do + conn = policy_conn("GET", "(request-target) host", [date_header(10)]) + assert Signature.check_signature_policy(conn, @now) == {:error, :date_not_signed} + end + + test "rejects a body request whose digest is not signed" do + conn = + policy_conn("POST", "(request-target) host date", [ + date_header(10), + {"digest", "SHA-256=abc"} + ]) + + assert Signature.check_signature_policy(conn, @now) == {:error, :digest_not_signed} + end + + test "rejects a signed date that is missing or unparsable" do + conn = policy_conn("GET", "(request-target) host date", []) + assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date} + + conn = policy_conn("GET", "(request-target) host date", [{"date", "yesterday"}]) + assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date} + end + + test "rejects more than one date header" do + conn = + policy_conn("GET", "(request-target) host date", [date_header(10), date_header(9000)]) + + assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date} + end + + test "rejects a signed digest when the body was not hashed" do + conn = + policy_conn("POST", "(request-target) host date digest", [ + date_header(10), + {"digest", "SHA-256=abc"} + ]) + |> Map.put(:assigns, %{}) + + assert Signature.check_signature_policy(conn, @now) == {:error, :body_not_hashed} + end + + test "rejects a signed date that is too old or too far in the future" do + clear_config([:activitypub, :signature_max_age], 7200) + clear_config([:activitypub, :signature_max_clock_skew], 3600) + + conn = policy_conn("GET", "(request-target) host date", [date_header(7200)]) + assert Signature.check_signature_policy(conn, @now) == :ok + + conn = policy_conn("GET", "(request-target) host date", [date_header(7201)]) + assert Signature.check_signature_policy(conn, @now) == {:error, :date_too_old} + + conn = policy_conn("GET", "(request-target) host date", [date_header(-3600)]) + assert Signature.check_signature_policy(conn, @now) == :ok + + conn = policy_conn("GET", "(request-target) host date", [date_header(-3601)]) + assert Signature.check_signature_policy(conn, @now) == {:error, :date_in_future} + end + + test "rejects a (created) timestamp that is too old" do + clear_config([:activitypub, :signature_max_age], 60) + created = DateTime.to_unix(@now) - 61 + + conn = + policy_conn("GET", "(request-target) (created) host", [], ",created=#{created}") + + assert Signature.check_signature_policy(conn, @now) == {:error, :date_too_old} + end + + test "uses the cavage default of a signed date when headers are not given" do + conn = %Plug.Conn{ + method: "GET", + req_headers: [{"signature", "keyId=\"https://remote.example/users/a#main-key\""}] + } + + assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date} + end + end + describe "key_id_to_actor_id/1" do test "it properly deduces the actor id for misskey" do assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") == From e5481b4c28497a9d655feb706406dc06d11aa991 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 21:46:40 +0400 Subject: [PATCH 2/3] 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. --- changelog.d/signature-freshness.security | 1 + .../activity_pub/activity_pub_controller.ex | 29 +++-- lib/pleroma/web/plugs/http_signature_plug.ex | 46 +++++-- .../activity_pub_controller_test.exs | 75 ++++++++--- .../plugs/http_signature_plug_mrf_test.exs | 6 +- .../plugs/http_signature_plug_policy_test.exs | 119 ++++++++++++++++++ .../web/plugs/http_signature_plug_test.exs | 112 +++++++++++++---- 7 files changed, 329 insertions(+), 59 deletions(-) create mode 100644 changelog.d/signature-freshness.security create mode 100644 test/pleroma/web/plugs/http_signature_plug_policy_test.exs diff --git a/changelog.d/signature-freshness.security b/changelog.d/signature-freshness.security new file mode 100644 index 000000000..a314c403e --- /dev/null +++ b/changelog.d/signature-freshness.security @@ -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 diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index e9b0a0d6d..dd5f6028f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -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 diff --git a/lib/pleroma/web/plugs/http_signature_plug.ex b/lib/pleroma/web/plugs/http_signature_plug.ex index 1f7cb51f3..be1c9c2ee 100644 --- a/lib/pleroma/web/plugs/http_signature_plug.ex +++ b/lib/pleroma/web/plugs/http_signature_plug.ex @@ -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) 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 13a4ee108..606d47c8d 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -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) diff --git a/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs b/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs index 1ee81b1a0..0101e4ab6 100644 --- a/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs +++ b/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs @@ -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 diff --git a/test/pleroma/web/plugs/http_signature_plug_policy_test.exs b/test/pleroma/web/plugs/http_signature_plug_policy_test.exs new file mode 100644 index 000000000..14279d4bc --- /dev/null +++ b/test/pleroma/web/plugs/http_signature_plug_policy_test.exs @@ -0,0 +1,119 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# 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 diff --git a/test/pleroma/web/plugs/http_signature_plug_test.exs b/test/pleroma/web/plugs/http_signature_plug_test.exs index 9d07270bb..bdab88939 100644 --- a/test/pleroma/web/plugs/http_signature_plug_test.exs +++ b/test/pleroma/web/plugs/http_signature_plug_test.exs @@ -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 From e417cd13830e2d33f2a67654f17e9984890cb778 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 29 Oct 2026 10:41:18 +0400 Subject: [PATCH 3/3] test(signature): build test signatures with sigils for credo --- test/pleroma/signature_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/pleroma/signature_test.exs b/test/pleroma/signature_test.exs index 18a2a1ddc..3c0ec598f 100644 --- a/test/pleroma/signature_test.exs +++ b/test/pleroma/signature_test.exs @@ -121,8 +121,8 @@ defmodule Pleroma.SignatureTest do defp policy_conn(method, signed_headers, extra_headers, extra_params \\ "") do signature = - "keyId=\"https://remote.example/users/a#main-key\",headers=\"#{signed_headers}\"" <> - extra_params <> ",signature=\"fake\"" + ~s(keyId="https://remote.example/users/a#main-key",headers="#{signed_headers}") <> + extra_params <> ~s(,signature="fake") %Plug.Conn{ method: method,