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") ==