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:
parent
23dfbd491e
commit
4c4982d611
8 changed files with 157 additions and 88 deletions
|
|
@ -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)
|
||||
|
|
|
|||
116
lib/pleroma/http/middleware/httpsignature.ex
Normal file
116
lib/pleroma/http/middleware/httpsignature.ex
Normal 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 doesn’t 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
|
||||
|
|
@ -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}} <-
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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], [])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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=="|
|
||||
|
|
|
|||
Loading…
Reference in a new issue