Re-sign requests when following redirects

To achieve this signatures are now generated by a custom
Tesla Middleware placed after the FollowRedirects Middleware.
Any requests which should be signed needs
to pass the signing key via opts.

This also unifies the associated header logic between fetching and
publishing, notably resolving a divergence wrt the "host" header.
Relevant spec demands the host header shall include a port
identification if not using the protocols standard port.

Fixes: https://akkoma.dev/AkkomaGang/akkoma/issues/731
This commit is contained in:
Oneric 2025-08-30 00:00:00 +00:00
parent 23dfbd491e
commit 4c4982d611
8 changed files with 157 additions and 88 deletions

View file

@ -72,7 +72,13 @@ def request(method, url, body, headers, options) when is_binary(url) do
options = put_in(options[:adapter], adapter_opts)
params = options[:params] || []
request = build_request(method, headers, options, url, body, params)
client = Tesla.client([Tesla.Middleware.FollowRedirects, Tesla.Middleware.Telemetry])
client =
Tesla.client([
Tesla.Middleware.FollowRedirects,
Pleroma.HTTP.Middleware.HTTPSignature,
Tesla.Middleware.Telemetry
])
Logger.debug("Outbound: #{method} #{url}")
request(client, request)

View file

@ -0,0 +1,116 @@
# Akkoma: Magically expressive social media
# Copyright © 2025 Akkoma Authors <https://akkoma.dev/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTTP.Middleware.HTTPSignature do
alias Pleroma.User.SigningKey
alias Pleroma.Signature
require Logger
@behaviour Tesla.Middleware
@moduledoc """
Adds a HTTP signature and related headers to requests, if a signing key is set in the request env.
If any other middleware can update the target location (e.g. redirects) this MUST be placed after all of them!
(Note: the third argument holds static middleware options from client creation)
"""
@impl true
def call(env, next, _options) do
env = maybe_sign(env)
Tesla.run(env, next)
end
defp maybe_sign(env) do
case Keyword.get(env.opts, :httpsig) do
%{signing_key: %SigningKey{} = key} ->
set_signature_headers(env, key)
_ ->
env
end
end
defp set_signature_headers(env, key) do
Logger.debug("Signing request to: #{env.url}")
{http_headers, signing_headers} = collect_headers_for_signature(env)
signature = Signature.sign(key, signing_headers, has_body: has_body(env))
set_headers(env, [{"signature", signature} | http_headers])
end
defp has_body(%{body: body}) when body in [nil, ""], do: false
defp has_body(_), do: true
defp set_headers(env, []), do: env
defp set_headers(env, [{key, val} | rest]) do
headers = :proplists.delete(key, env.headers)
headers = [{key, val} | headers]
set_headers(%{env | headers: headers}, rest)
end
# Returns tuple.
# First element is headers+values which need to be added to the HTTP request.
# Second element are all headers to be used for signing, including already existing and pseudo headers.
defp collect_headers_for_signature(env) do
{request_target, host} = get_request_target_and_host(env)
date = Pleroma.Signature.signed_date()
# content-length is always automatically set later on
# since they are needed to establish working connection.
# Similarly host will always be set for HTTP/1, and technically may be omitted for HTTP/2+
# but Tesla doesnt handle it well if we preset it ourselves (and seems to set it even for HTTP/2 anyway)
http_headers = [{"date", date}]
signing_headers = %{
"(request-target)" => request_target,
"host" => host,
"date" => date
}
if has_body(env) do
append_body_headers(env, http_headers, signing_headers)
else
{http_headers, signing_headers}
end
end
defp append_body_headers(env, http_headers, signing_headers) do
content_length = byte_size(env.body)
digest = digest_value(env)
http_headers = [{"digest", digest} | http_headers]
signing_headers =
Map.merge(signing_headers, %{
"digest" => digest,
"content-length" => content_length
})
{http_headers, signing_headers}
end
defp get_request_target_and_host(env) do
uri = URI.parse(env.url)
rt = "#{env.method} #{uri.path}"
host = host_from_uri(uri)
{rt, host}
end
defp digest_value(env) do
# case Tesla.get_header(env, "digest")
encoded_hash = :crypto.hash(:sha256, env.body) |> Base.encode64()
"SHA-256=" <> encoded_hash
end
defp host_from_uri(%URI{port: port, scheme: scheme, host: host}) do
# https://httpwg.org/specs/rfc9110.html#field.host
# https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.3
if port == URI.default_port(scheme) do
host
else
"#{host}:#{port}"
end
end
end

View file

@ -9,7 +9,6 @@ defmodule Pleroma.Object.Fetcher do
alias Pleroma.Object
alias Pleroma.Object.Containment
alias Pleroma.Repo
alias Pleroma.Signature
alias Pleroma.Web.ActivityPub.InternalFetchActor
alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.Transmogrifier
@ -227,36 +226,6 @@ defp prepare_activity_params(data) do
|> Maps.put_if_present("bcc", data["bcc"])
end
defp make_signature(id, date) do
uri = URI.parse(id)
signature =
InternalFetchActor.get_actor()
|> Signature.sign(%{
"(request-target)" => "get #{uri.path}",
"host" => uri.host,
"date" => date
})
{"signature", signature}
end
defp sign_fetch(headers, id, date) do
if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do
[make_signature(id, date) | headers]
else
headers
end
end
defp maybe_date_fetch(headers, date) do
if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do
[{"date", date} | headers]
else
headers
end
end
@doc """
Fetches arbitrary remote object and performs basic safety and authenticity checks.
When the fetch URL is known to already be a canonical AP id, checks are stricter.
@ -402,20 +371,25 @@ defp get_final_id(final_url, _intial_url) do
@doc "Do NOT use; only public for use in tests"
def get_object(id) do
date = Pleroma.Signature.signed_date()
headers =
[
# The first is required by spec, the second provided as a fallback for buggy implementations
{"accept", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""},
{"accept", "application/activity+json"}
]
|> maybe_date_fetch(date)
|> sign_fetch(id, date)
http_opts =
if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do
signing_actor = InternalFetchActor.get_actor() |> Pleroma.User.SigningKey.load_key()
signing_key = signing_actor.signing_key
[httpsig: %{signing_key: signing_key}]
else
[]
end
with {:ok, %{body: body, status: code, headers: headers, url: final_url}}
when code in 200..299 <-
HTTP.Backoff.get(id, headers),
HTTP.Backoff.get(id, headers, http_opts),
{:has_content_type, {_, content_type}} <-
{:has_content_type, List.keyfind(headers, "content-type", 0)},
{:parse_content_type, {:ok, "application", subtype, type_params}} <-

View file

@ -62,14 +62,16 @@ defp handle_common_errors(error, kid, action_name) do
end
end
def sign(%User{} = user, headers, opts \\ []) do
with {:ok, private_key} <- SigningKey.private_key(user) do
def sign(%SigningKey{} = key, headers, opts \\ []) do
with {:ok, private_key_binary} <- SigningKey.private_key_binary(key) do
HTTPSignatures.sign(
%HTTPKey{key: private_key},
SigningKey.local_key_id(user.ap_id),
%HTTPKey{key: private_key_binary},
key.key_id,
headers,
opts
)
else
_ -> raise "Tried to sign with #{key.key_id} but it has no private key!"
end
end

View file

@ -136,24 +136,22 @@ def public_key_pem(_e) do
{:error, "key not found"}
end
@spec private_key(User.t()) :: {:ok, binary()} | {:error, String.t()}
@spec private_key_binary(__MODULE__) :: {:ok, binary()} | {:error, String.t()}
@doc """
Given a user, return the private key for that user in binary format.
Given a key, return the corresponding private key in binary format.
"""
def private_key(%User{} = user) do
case Repo.preload(user, :signing_key) do
%{signing_key: %__MODULE__{private_key: private_key_pem}} ->
key =
private_key_pem
|> :public_key.pem_decode()
|> hd()
|> :public_key.pem_entry_decode()
def private_key_binary(%__MODULE__{private_key: private_key_pem}) do
key =
private_key_pem
|> :public_key.pem_decode()
|> hd()
|> :public_key.pem_entry_decode()
{:ok, key}
{:ok, key}
end
_ ->
{:error, "key not found"}
end
def private_key_binary(%__MODULE__{} = key) do
{:error, "key #{key.key_id} has no private key"}
end
@spec get_or_fetch_by_key_id(String.t()) :: {:ok, __MODULE__} | {:error, String.t()}

View file

@ -51,34 +51,15 @@ def publish_one(
%{"inbox" => inbox, "json" => json, "actor" => %User{} = actor, "id" => id} = params
) do
Logger.debug("Federating #{id} to #{inbox}")
uri = %{path: path} = URI.parse(inbox)
digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64())
date = Pleroma.Signature.signed_date()
signature =
Pleroma.Signature.sign(
actor,
%{
"(request-target)" => "post #{path}",
"host" => signature_host(uri),
"content-length" => byte_size(json),
"digest" => digest,
"date" => date
},
has_body: true
)
signing_key = Pleroma.User.SigningKey.load_key(actor).signing_key
with {:ok, %{status: code}} = result when code in 200..299 <-
HTTP.post(
inbox,
json,
[
{"Content-Type", "application/activity+json"},
{"Date", date},
{"signature", signature},
{"digest", digest}
]
[{"content-type", "application/activity+json"}],
httpsig: %{signing_key: signing_key}
) do
if not Map.has_key?(params, "unreachable_since") || params["unreachable_since"] do
Instances.set_reachable(inbox)
@ -101,14 +82,6 @@ def publish_one(%{"actor_id" => actor_id} = params) do
|> publish_one()
end
defp signature_host(%URI{port: port, scheme: scheme, host: host}) do
if port == URI.default_port(scheme) do
host
else
"#{host}:#{port}"
end
end
defp blocked_instances do
Config.get([:instance, :quarantined_instances], []) ++
Config.get([:mrf_simple, :reject], [])

View file

@ -478,7 +478,7 @@ test "it can refetch pruned objects" do
Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367")
assert called(Pleroma.Signature.sign(:_, :_))
assert called(Pleroma.Signature.sign(:_, :_, :_))
end
test_with_mock "it doesn't sign fetches when not configured to do so",
@ -489,7 +489,7 @@ test "it can refetch pruned objects" do
Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367")
refute called(Pleroma.Signature.sign(:_, :_))
refute called(Pleroma.Signature.sign(:_, :_, :_))
end
end

View file

@ -130,7 +130,7 @@ test "it returns signature headers" do
assert_signature_equal(
Signature.sign(
user,
user.signing_key,
headers
),
~s|keyId="https://mastodon.social/users/lambadalambda#main-key",algorithm="rsa-sha256",headers="(request-target) content-length date digest host",signature="fhOT6IBThnCo6rv2Tv8BRXLV7LvVf/7wTX/bbPLtdq5A4GUqrmXUcY5p77jQ6NU9IRIVczeeStxQV6TrHqk/qPdqQOzDcB6cWsSfrB1gsTinBbAWdPzQYqUOTl+Minqn2RERAfPebKYr9QGa0sTODDHvze/UFPuL8a1lDO2VQE0lRCdg49Igr8pGl/CupUx8Fb874omqP0ba3M+siuKEwo02m9hHcbZUeLSN0ZVdvyTMttyqPM1BfwnFXkaQRAblLTyzt4Fv2+fTN+zPipSxJl1YIo1TsmwNq9klqImpjh8NHM3MJ5eZxTZ109S6Q910n1Lm46V/SqByDaYeg9g7Jw=="|