Merge pull request 'Federation: reject stale and incomplete HTTP signatures' (#8033) from lambadalambda/pleroma:fix/signature-freshness into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/8033
This commit is contained in:
lain 2026-10-29 09:35:47 +00:00
commit ca44028928
12 changed files with 563 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

@ -373,6 +373,9 @@ config :pleroma, :activitypub,
note_replies_output_limit: 5, note_replies_output_limit: 5,
sign_object_fetches: true, sign_object_fetches: true,
authorized_fetch_mode: false, authorized_fetch_mode: false,
signature_policy: :enforce,
signature_max_age: 7200,
signature_max_clock_skew: 3600,
client_api_enabled: false, client_api_enabled: false,
anonymize_reporter: false, anonymize_reporter: false,
anonymize_reporter_local_nickname: "" anonymize_reporter_local_nickname: ""

View file

@ -1788,6 +1788,27 @@ config :pleroma, :config_description, [
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)" "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, key: :note_replies_output_limit,
type: :integer, type: :integer,

View file

@ -286,6 +286,9 @@ Notes:
* `sign_object_fetches`: Sign object fetches with HTTP signatures * `sign_object_fetches`: Sign object fetches with HTTP signatures
* `authorized_fetch_mode`: Require HTTP signatures for AP fetches * `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) * `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 ## Pleroma.User

View file

@ -95,6 +95,84 @@ defmodule Pleroma.Signature do
Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT")
end 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() @spec validate_signature(Plug.Conn.t(), String.t()) :: boolean()
def validate_signature(%Plug.Conn{} = conn, request_target) do def validate_signature(%Plug.Conn{} = conn, request_target) do
# Newer drafts for HTTP signatures now use @request-target instead of the # Newer drafts for HTTP signatures now use @request-target instead of the

View file

@ -386,18 +386,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end end
def inbox(%{assigns: %{valid_signature: false}} = conn, params) do def inbox(%{assigns: %{valid_signature: false}} = conn, params) do
if unsupported_http_message_signature?(conn) do cond do
reject_unsupported_http_message_signature(conn) unsupported_http_message_signature?(conn) ->
else reject_unsupported_http_message_signature(conn)
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") 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
end end

View file

@ -45,20 +45,52 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
defp maybe_assign_valid_signature(conn) do defp maybe_assign_valid_signature(conn) do
if has_signature_header?(conn) do if has_signature_header?(conn) do
# we replace the digest header with the one we computed in DigestPlug conn = put_computed_digest(conn)
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)) case check_signature_policy(conn) do
:ok ->
assign(conn, :valid_signature, Signature.validate_signature(conn))
{:error, reason} ->
reject_signature(conn, reason)
end
else else
Logger.debug("No signature header!") Logger.debug("No signature header!")
conn conn
end end
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 defp maybe_assign_actor_id(%{assigns: %{valid_signature: true}} = conn) do
adapter = Application.get_env(:http_signatures, :adapter) adapter = Application.get_env(:http_signatures, :adapter)

View file

@ -116,6 +116,135 @@ defmodule Pleroma.SignatureTest do
end end
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 describe "key_id_to_actor_id/1" do
test "it properly deduces the actor id for misskey" do test "it properly deduces the actor id for misskey" do
assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") == assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") ==

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Delivery alias Pleroma.Delivery
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Signature
alias Pleroma.Tests.ObanHelpers alias Pleroma.Tests.ObanHelpers
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -49,6 +50,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
|> put_req_header("signature", "keyId=\"#{actor_id}#main-key\"") |> put_req_header("signature", "keyId=\"#{actor_id}#main-key\"")
end 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 defp expect_signature_retry_from(%User{} = signer) do
signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured") 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 =
conn conn
|> put_req_header("content-type", "application/activity+json") |> 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("content-digest", "sha-256=:fake-digest:")
|> put_req_header( |> put_req_header(
"signature-input", "signature-input",
@ -832,12 +843,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
legacy_signature = legacy_signature =
"keyId=\"https://activitypubbot.example/user/ok#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-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 =
conn conn
|> put_req_header("content-type", "application/activity+json") |> 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("digest", "SHA-256=fake-digest")
|> put_req_header("signature", "sig1=:fake-signature:") |> put_req_header("signature", "sig1=:fake-signature:")
|> Map.update!(:req_headers, &[{"signature", legacy_signature} | &1]) |> Map.update!(:req_headers, &[{"signature", legacy_signature} | &1])
@ -871,7 +882,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn = conn =
conn conn
|> put_req_header("content-type", "application/activity+json") |> 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("digest", "SHA-256=fake-digest")
|> put_req_header( |> put_req_header(
"signature-input", "signature-input",
@ -881,12 +892,44 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
"signature", "signature",
"keyId=\"https://activitypubbot.example/user/ok#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-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 "ok" == json_response(conn, 200)
assert [_] = all_enqueued(worker: SignatureRetryWorker) assert [_] = all_enqueued(worker: SignatureRetryWorker)
end 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 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") alice = insert(:user, local: false, ap_id: "https://one.com/users/alice")
bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") bob = insert(:user, local: false, ap_id: "https://two.com/users/bob")
@ -918,8 +961,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn conn
|> assign(:valid_signature, false) |> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json") |> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") |> put_fake_signature(alice.ap_id)
|> post("/inbox", data) |> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200) assert "ok" == json_response(conn, 200)
@ -950,8 +993,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn conn
|> assign(:valid_signature, false) |> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json") |> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") |> put_fake_signature(alice.ap_id)
|> post("/inbox", data) |> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200) assert "ok" == json_response(conn, 200)
@ -982,8 +1025,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn conn
|> assign(:valid_signature, false) |> assign(:valid_signature, false)
|> put_req_header("content-type", "application/activity+json") |> put_req_header("content-type", "application/activity+json")
|> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") |> put_fake_signature(alice.ap_id)
|> post("/inbox", data) |> post("/inbox", Jason.encode!(data))
assert "ok" == json_response(conn, 200) assert "ok" == json_response(conn, 200)
@ -1025,13 +1068,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn = conn =
conn conn
|> put_req_header("content-type", "application/activity+json") |> 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("digest", "SHA-256=fake-digest")
|> put_req_header( |> put_req_header(
"signature", "signature",
"keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-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 conn.assigns.valid_signature == false
assert "ok" == json_response(conn, 200) assert "ok" == json_response(conn, 200)
@ -1063,13 +1106,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
conn = conn =
conn conn
|> put_req_header("content-type", "application/activity+json") |> 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("digest", "SHA-256=fake-digest")
|> put_req_header( |> put_req_header(
"signature", "signature",
"keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-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 conn.assigns.valid_signature == false
assert "ok" == json_response(conn, 200) assert "ok" == json_response(conn, 200)

View file

@ -26,7 +26,11 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugMRFTest do
|> expect(:validate_conn, fn _ -> true end) |> expect(:validate_conn, fn _ -> true end)
build_conn(:get, "/doesntmattter", %{"actor" => actor}) 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") |> put_format("activity+json")
|> HTTPSignaturePlug.call(%{}) |> HTTPSignaturePlug.call(%{})
end 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 Phoenix.Controller, only: [put_format: 2]
import Plug.Conn 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 test "it calls HTTPSignatures to check validity if the actor signed it" do
params = %{"actor" => "http://mastodon.example.org/users/admin"} params = %{"actor" => "http://mastodon.example.org/users/admin"}
conn = build_conn(:get, "/doesntmattter", params) conn = build_conn(:get, "/doesntmattter", params)
@ -22,10 +31,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn = conn =
conn conn
|> put_req_header( |> put_signature("http://mastodon.example.org/users/admin")
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_format("activity+json") |> put_format("activity+json")
|> HTTPSignaturePlug.call(%{}) |> HTTPSignaturePlug.call(%{})
@ -51,10 +57,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn = conn =
orig_conn orig_conn
|> put_req_header( |> put_signature("http://mastodon.example.org/users/admin")
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{}) |> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == false assert conn.assigns.valid_signature == false
@ -71,10 +74,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn = conn =
orig_conn orig_conn
|> put_req_header( |> put_signature("http://mastodon.example.org/users/admin")
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{}) |> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true assert conn.assigns.valid_signature == true
@ -108,10 +108,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn = conn =
conn conn
|> Map.put(:remote_ip, {192, 168, 0, 1}) |> Map.put(:remote_ip, {192, 168, 0, 1})
|> put_req_header( |> put_signature("http://mastodon.example.org/users/admin")
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{}) |> HTTPSignaturePlug.call(%{})
assert conn.remote_ip == {192, 168, 0, 1} assert conn.remote_ip == {192, 168, 0, 1}
@ -131,10 +128,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn = conn =
build_conn(:get, "/doesntmattter", %{"actor" => "http://mastodon.example.org/users/admin"}) build_conn(:get, "/doesntmattter", %{"actor" => "http://mastodon.example.org/users/admin"})
|> put_req_header( |> put_signature("http://mastodon.example.org/users/admin")
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_format("activity+json") |> put_format("activity+json")
|> HTTPSignaturePlug.call(%{}) |> HTTPSignaturePlug.call(%{})
@ -152,14 +146,84 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn = conn =
build_conn(:get, "/doesntmattter", %{"actor" => "http://allowed.example.org/users/admin"}) build_conn(:get, "/doesntmattter", %{"actor" => "http://allowed.example.org/users/admin"})
|> put_req_header( |> put_signature("http://allowed.example.org/users/admin")
"signature",
"keyId=\"http://allowed.example.org/users/admin#main-key"
)
|> put_format("activity+json") |> put_format("activity+json")
|> HTTPSignaturePlug.call(%{}) |> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true assert conn.assigns.valid_signature == true
assert conn.halted == false assert conn.halted == false
end 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 end