Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
34 KB
Referenced Files
None
Subscribers
None
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/config/config.exs b/config/config.exs
index 32f5b6c20..576ae28db 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 67ae4a585..81bc358a9 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/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
-
- assign(conn, :valid_signature, Signature.validate_signature(conn))
+ conn = put_computed_digest(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/signature_test.exs b/test/pleroma/signature_test.exs
index 0c7c4c840..3c0ec598f 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 =
+ ~s(keyId="https://remote.example/users/a#main-key",headers="#{signed_headers}") <>
+ extra_params <> ~s(,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") ==
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 <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
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

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 2:35 AM (19 h, 46 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1800920
Default Alt Text
(34 KB)

Event Timeline