Federation: reject stale and incomplete HTTP signatures #8033

Merged
lambadalambda merged 3 commits from lambadalambda/pleroma:fix/signature-freshness into develop 2026-10-29 09:35:48 +00:00

Incoming HTTP signatures were checked only cryptographically. A captured signed GET could be replayed forever (defeating authorized fetch), and a signed POST that did not cover digest validated with any body.

New checks before the signature is verified (cavage signatures):

  • date or (created) must be signed, and the signed time must be within signature_max_age (default 2 h, like Akkoma) and signature_max_clock_skew (default 1 h, like Mastodon).
  • Requests with a body must sign digest, and the body must have been hashed by DigestPlug.
  • Exactly one Date header (a second, fresh Date header could otherwise hide an old signed one).

Failing GETs count as unsigned; failing inbox POSTs get 401 and are not queued (the signature retry worker only re-checks the cryptography). config :pleroma, :activitypub, signature_policy: :log only logs instead. Mastodon already enforces stricter rules (5 min + 1 h skew), so every sender that federates with Mastodon passes. Pleroma's own publisher and fetcher requests are tested end to end against the new checks.

Based on Akkoma's http_signatures changes.

Decision: :enforce stays the default.

Incoming HTTP signatures were checked only cryptographically. A captured signed GET could be replayed forever (defeating authorized fetch), and a signed POST that did not cover `digest` validated with any body. New checks before the signature is verified (cavage signatures): - `date` or `(created)` must be signed, and the signed time must be within `signature_max_age` (default 2 h, like Akkoma) and `signature_max_clock_skew` (default 1 h, like Mastodon). - Requests with a body must sign `digest`, and the body must have been hashed by DigestPlug. - Exactly one `Date` header (a second, fresh `Date` header could otherwise hide an old signed one). Failing GETs count as unsigned; failing inbox POSTs get 401 and are not queued (the signature retry worker only re-checks the cryptography). `config :pleroma, :activitypub, signature_policy: :log` only logs instead. Mastodon already enforces stricter rules (5 min + 1 h skew), so every sender that federates with Mastodon passes. Pleroma's own publisher and fetcher requests are tested end to end against the new checks. Based on Akkoma's http_signatures changes. Decision: `:enforce` stays the default.
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).
fix(federation): enforce the signature policy on incoming requests
Some checks failed
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/unit-testing-elixir-1.17 unknown status
ci/woodpecker/pr/unit-testing-elixir-1.19 unknown status
e5481b4c28
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.
Author
Owner

/oc review

/oc review
opencode-fix-bot approved these changes 2026-10-27 18:39:21 +00:00
Dismissed
opencode-fix-bot left a comment

opencode-agent:job=133:generation=1

Review of the complete base-to-head diff plus surrounding code (plug pipeline, DigestPlug, inbox controller, publisher/fetcher signing, retry worker, http_signatures 0.1.3 split_signature/validate semantics).

No findings. The new check_signature_policy runs before cryptographic verification and is fail-closed in every mismatch I could construct:

  • date or (created) must be signed, and the checked value is exactly the value the crypto check uses (single Date header enforced, so the signed bytes and checked bytes cannot diverge; (created) is read from the same parsed signature param the signing string is built from).
  • Non-GET/HEAD requests must sign digest, and the digest the crypto check verifies is the DigestPlug-computed hash of the received body (assigns[:digest] required, so an unhashed body cannot pass). The plug runs in the S2S/AP pipelines where every signed POST carries a parsed body, so no legitimate route is left without a computed digest.
  • Enforcement points line up: stale/under-covered GETs degrade to unsigned (same as a bad signature today), inbox POSTs get 401 with the reason and skip the retry queue, which is correct because SignatureRetryWorker re-checks only the cryptography and could otherwise accept a stale-but-valid signature later. :log mode explicitly downgrades to a warning.
  • Self-interop holds: the publisher signs (request-target) host content-length digest date and the fetcher signs (request-target) host date, both covered end to end by the new tests, including the cavage-default (date) and GoToSocial-style ((created)) cases.

Merge-readiness: 5/5 - production-ready, no known findings.

opencode-agent:job=133:generation=1 Review of the complete base-to-head diff plus surrounding code (plug pipeline, DigestPlug, inbox controller, publisher/fetcher signing, retry worker, http_signatures 0.1.3 split_signature/validate semantics). No findings. The new check_signature_policy runs before cryptographic verification and is fail-closed in every mismatch I could construct: - date or (created) must be signed, and the checked value is exactly the value the crypto check uses (single Date header enforced, so the signed bytes and checked bytes cannot diverge; (created) is read from the same parsed signature param the signing string is built from). - Non-GET/HEAD requests must sign digest, and the digest the crypto check verifies is the DigestPlug-computed hash of the received body (assigns[:digest] required, so an unhashed body cannot pass). The plug runs in the S2S/AP pipelines where every signed POST carries a parsed body, so no legitimate route is left without a computed digest. - Enforcement points line up: stale/under-covered GETs degrade to unsigned (same as a bad signature today), inbox POSTs get 401 with the reason and skip the retry queue, which is correct because SignatureRetryWorker re-checks only the cryptography and could otherwise accept a stale-but-valid signature later. :log mode explicitly downgrades to a warning. - Self-interop holds: the publisher signs (request-target) host content-length digest date and the fetcher signs (request-target) host date, both covered end to end by the new tests, including the cavage-default (date) and GoToSocial-style ((created)) cases. Merge-readiness: 5/5 - production-ready, no known findings.
test(signature): build test signatures with sigils for credo
All checks were successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/unit-testing-elixir-1.17 Pipeline was successful
ci/woodpecker/pr/unit-testing-elixir-1.19 Pipeline was successful
e417cd1383
Author
Owner

/oc review

/oc review
opencode-fix-bot left a comment

opencode-agent:job=139:generation=1

Review of the complete base-to-head diff and the surrounding enforcement path (HTTPSignaturePlug, DigestPlug body_reader, inbox controller, publisher/fetcher signing, SignatureRetryWorker, http_signatures 0.1.3 signature parsing/validation).

No findings. The new check runs before cryptographic verification and is fail-closed in every gap I could construct:

  • date or (created) must be signed, and the value the window check reads is exactly the value the crypto check verifies: duplicate Date headers are rejected outright, so the library's last-wins header-map collapse can never diverge from the checked value. A missing or unparsable date fails closed, as does the cavage date default when no headers parameter is present.
  • Non-GET/HEAD requests must sign digest, and the digest the crypto check verifies is the DigestPlug-computed hash of the received body (assigns[:digest] is required, so an unhashed body cannot pass; the computed digest replaces the header before both checks run).
  • A future (created) is still rejected end to end: the signature library itself only accepts (created) timestamps at or before now, so the policy's skew window for (created) cannot admit a future timestamp past the cryptographic check.
  • Enforcement points line up: stale or under-covered GETs degrade to unsigned (same as a bad signature today, including the authorized-fetch 401); inbox POSTs get 401 with the reason and skip the retry queue, which is correct because the retry worker re-checks only the cryptographic signature and could otherwise accept a stale-but-valid signature later. :log mode explicitly downgrades violations to a warning.
  • Self-interop holds: the publisher signs digest plus date and the fetcher signs date, both covered end to end by the new tests, including the cavage-default (date) and (created) cases.

I also ran the touched suites against this head: signature tests (26 tests), plug policy plus plug tests (12), inbox controller tests (137), plug MRF test (1) — all pass.

Merge-readiness: 5/5 - production-ready, no known findings.

opencode-agent:job=139:generation=1 Review of the complete base-to-head diff and the surrounding enforcement path (HTTPSignaturePlug, DigestPlug body_reader, inbox controller, publisher/fetcher signing, SignatureRetryWorker, http_signatures 0.1.3 signature parsing/validation). No findings. The new check runs before cryptographic verification and is fail-closed in every gap I could construct: - `date` or `(created)` must be signed, and the value the window check reads is exactly the value the crypto check verifies: duplicate `Date` headers are rejected outright, so the library's last-wins header-map collapse can never diverge from the checked value. A missing or unparsable date fails closed, as does the cavage `date` default when no `headers` parameter is present. - Non-GET/HEAD requests must sign `digest`, and the digest the crypto check verifies is the DigestPlug-computed hash of the received body (`assigns[:digest]` is required, so an unhashed body cannot pass; the computed digest replaces the header before both checks run). - A future `(created)` is still rejected end to end: the signature library itself only accepts `(created)` timestamps at or before now, so the policy's skew window for `(created)` cannot admit a future timestamp past the cryptographic check. - Enforcement points line up: stale or under-covered GETs degrade to unsigned (same as a bad signature today, including the authorized-fetch 401); inbox POSTs get 401 with the reason and skip the retry queue, which is correct because the retry worker re-checks only the cryptographic signature and could otherwise accept a stale-but-valid signature later. `:log` mode explicitly downgrades violations to a warning. - Self-interop holds: the publisher signs digest plus date and the fetcher signs date, both covered end to end by the new tests, including the cavage-default (`date`) and `(created)` cases. I also ran the touched suites against this head: signature tests (26 tests), plug policy plus plug tests (12), inbox controller tests (137), plug MRF test (1) — all pass. Merge-readiness: 5/5 - production-ready, no known findings.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pleroma/pleroma!8033
No description provided.