fix: Verify body digest vs signing header
This commit is contained in:
parent
2549a8db2a
commit
1bd8ce9c6b
9 changed files with 66 additions and 30 deletions
15
README.md
15
README.md
|
|
@ -1,5 +1,6 @@
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
|
- [x] Check that signature header (digest) matches digest of body contents
|
||||||
- [ ] Posting
|
- [ ] Posting
|
||||||
- [x] Making posts locally
|
- [x] Making posts locally
|
||||||
- [x] Figuring out follower list
|
- [x] Figuring out follower list
|
||||||
|
|
@ -7,24 +8,28 @@
|
||||||
- [x] Post formatting
|
- [x] Post formatting
|
||||||
- [ ] Sending posts w/ images / videos
|
- [ ] Sending posts w/ images / videos
|
||||||
- [ ] Receiving posts w/ images / videos
|
- [ ] Receiving posts w/ images / videos
|
||||||
- [ ] Timeline
|
- [x] Timeline
|
||||||
- [x] My posts
|
- [x] My posts
|
||||||
- [x] Posts from accounts you follow
|
- [x] Posts from accounts you follow
|
||||||
- [ ] Show the actor avatar and display name
|
- [x] Show the actor avatar and display name
|
||||||
- [ ] Deleting posts
|
- [ ] Deleting posts
|
||||||
- [ ] Profile
|
- [x] Profile
|
||||||
- [ ] Name field (for display name)
|
- [x] Name field (for display name)
|
||||||
|
- [x] Bust actor cache when you update your profile
|
||||||
- [x] Following
|
- [x] Following
|
||||||
- [ ] Scrape public posts from the outbox when you follow
|
- [ ] Scrape public posts from the outbox when you follow
|
||||||
- [ ] Unfollowing
|
- [ ] Unfollowing
|
||||||
- [x] Being followed
|
- [x] Being followed
|
||||||
- [x] Accepting follows
|
- [x] Accepting follows
|
||||||
- [ ] Approving / declining follows and authorized instance list
|
- [ ] Blocking
|
||||||
|
- [ ] Approving / declining follows
|
||||||
|
- [ ] Manage authorized instance list
|
||||||
- [ ] Liking
|
- [ ] Liking
|
||||||
- [ ] Unliking
|
- [ ] Unliking
|
||||||
- [ ] CW posts
|
- [ ] CW posts
|
||||||
- [ ] Polls
|
- [ ] Polls
|
||||||
- [ ] DMs
|
- [ ] DMs
|
||||||
|
- [ ] Support authenticated fetch of outbox (by allowed domains / servers)
|
||||||
- [ ] Followers-only posts (or maybe this is handled because we only send posts to followers? but we also include public in the TO field?)
|
- [ ] Followers-only posts (or maybe this is handled because we only send posts to followers? but we also include public in the TO field?)
|
||||||
|
|
||||||
## UX
|
## UX
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,23 @@ defmodule ActivityPub.Headers do
|
||||||
]
|
]
|
||||||
|
|
||||||
def signing_headers(method, url, body, actor_url, private_key) do
|
def signing_headers(method, url, body, actor_url, private_key) do
|
||||||
url = case url do
|
url =
|
||||||
|
case url do
|
||||||
url when is_struct(url, URI) ->
|
url when is_struct(url, URI) ->
|
||||||
url
|
url
|
||||||
|
|
||||||
url when is_binary(url) ->
|
url when is_binary(url) ->
|
||||||
URI.parse(url)
|
URI.parse(url)
|
||||||
end
|
end
|
||||||
|
|
||||||
private_key = case private_key do
|
private_key =
|
||||||
|
case private_key do
|
||||||
"-----BEGIN" <> _ = key_pem ->
|
"-----BEGIN" <> _ = key_pem ->
|
||||||
key_pem
|
key_pem
|
||||||
|> :public_key.pem_decode()
|
|> :public_key.pem_decode()
|
||||||
|> hd()
|
|> hd()
|
||||||
|> :public_key.pem_entry_decode()
|
|> :public_key.pem_entry_decode()
|
||||||
|
|
||||||
private_key ->
|
private_key ->
|
||||||
private_key
|
private_key
|
||||||
end
|
end
|
||||||
|
|
@ -51,7 +55,7 @@ defmodule ActivityPub.Headers do
|
||||||
[{"signature", signature_header} | headers]
|
[{"signature", signature_header} | headers]
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify(method, path, headers, actor_fetcher \\ &fetch_actor_key/1) do
|
def verify(method, path, headers, body, actor_fetcher \\ &fetch_actor_key/1) do
|
||||||
{_key, signature_header} = Enum.find(headers, fn {key, _} -> key == "signature" end)
|
{_key, signature_header} = Enum.find(headers, fn {key, _} -> key == "signature" end)
|
||||||
|
|
||||||
signature_kv = SignatureSplitter.split(signature_header)
|
signature_kv = SignatureSplitter.split(signature_header)
|
||||||
|
|
@ -59,6 +63,13 @@ defmodule ActivityPub.Headers do
|
||||||
signature = signature_kv |> find_value("signature") |> Base.decode64!()
|
signature = signature_kv |> find_value("signature") |> Base.decode64!()
|
||||||
signing_text_headers = signature_kv |> find_value("headers") |> String.split(" ")
|
signing_text_headers = signature_kv |> find_value("headers") |> String.split(" ")
|
||||||
|
|
||||||
|
digest = :crypto.hash(:sha256, body)
|
||||||
|
expected_digest_header = "SHA-256=#{Base.encode64(digest)}"
|
||||||
|
actual_digest_header = find_value(headers, "digest")
|
||||||
|
|
||||||
|
if expected_digest_header != actual_digest_header do
|
||||||
|
{:error, :digest}
|
||||||
|
else
|
||||||
to_verify =
|
to_verify =
|
||||||
signing_text(signing_text_headers, [request_target_pseudoheader(method, path) | headers])
|
signing_text(signing_text_headers, [request_target_pseudoheader(method, path) | headers])
|
||||||
|
|
||||||
|
|
@ -70,6 +81,7 @@ defmodule ActivityPub.Headers do
|
||||||
error
|
error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def request_target_pseudoheader(method, path) do
|
def request_target_pseudoheader(method, path) do
|
||||||
formatted_method = String.downcase("#{method}")
|
formatted_method = String.downcase("#{method}")
|
||||||
|
|
|
||||||
18
lib/postland_web/cache_body_reader.ex
Normal file
18
lib/postland_web/cache_body_reader.ex
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
defmodule PostlandWeb.CacheBodyReader do
|
||||||
|
@moduledoc """
|
||||||
|
Inspired by https://hexdocs.pm/plug/1.6.0/Plug.Parsers.html#module-custom-body-reader
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Plug.Conn
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Read the raw body and store it for later use in the connection.
|
||||||
|
It ignores the updated connection returned by `Plug.Conn.read_body/2` to not break CSRF.
|
||||||
|
"""
|
||||||
|
@spec read_body(Conn.t(), Plug.opts()) :: {:ok, String.t(), Conn.t()}
|
||||||
|
def read_body(conn, opts) do
|
||||||
|
{:ok, body, _conn} = Conn.read_body(conn, opts)
|
||||||
|
conn = update_in(conn.assigns[:raw_body], &[body | &1 || []])
|
||||||
|
{:ok, body, conn}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule PostlandWeb.InboxController do
|
||||||
alias Postland.Activities
|
alias Postland.Activities
|
||||||
|
|
||||||
def post(conn, params) do
|
def post(conn, params) do
|
||||||
if Headers.verify(conn.method, conn.request_path, conn.req_headers) do
|
if Headers.verify(conn.method, conn.request_path, conn.req_headers, conn.assigns.raw_body) do
|
||||||
case Activities.process_activity(params) do
|
case Activities.process_activity(params) do
|
||||||
{:ok, _activity} ->
|
{:ok, _activity} ->
|
||||||
send_resp(conn, 200, Jason.encode!(params))
|
send_resp(conn, 200, Jason.encode!(params))
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ defmodule PostlandWeb.OutboxController do
|
||||||
"orderedItems" => []
|
"orderedItems" => []
|
||||||
}
|
}
|
||||||
|
|
||||||
if Headers.verify(conn.method, conn.request_path, conn.req_headers) do
|
if Headers.verify(conn.method, conn.request_path, conn.req_headers, conn.assigns.raw_body) do
|
||||||
Plug.Conn.send_resp(conn, 200, Jason.encode!(json))
|
Plug.Conn.send_resp(conn, 200, Jason.encode!(json))
|
||||||
else
|
else
|
||||||
send_resp(conn, 403, "forbidden")
|
send_resp(conn, 403, "forbidden")
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ defmodule PostlandWeb.Endpoint do
|
||||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||||
|
|
||||||
plug Plug.Parsers,
|
plug Plug.Parsers,
|
||||||
|
body_reader: {PostlandWeb.CacheBodyReader, :read_body, []},
|
||||||
parsers: [:urlencoded, :multipart, :json],
|
parsers: [:urlencoded, :multipart, :json],
|
||||||
pass: ["*/*"],
|
pass: ["*/*"],
|
||||||
json_decoder: Phoenix.json_library()
|
json_decoder: Phoenix.json_library()
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ defmodule PostlandWeb.ProfileLive do
|
||||||
case Repo.update(changeset) do
|
case Repo.update(changeset) do
|
||||||
{:ok, account} ->
|
{:ok, account} ->
|
||||||
form = account |> User.profile_changeset(%{}) |> to_form()
|
form = account |> User.profile_changeset(%{}) |> to_form()
|
||||||
|
Cachex.del(:main_cache, "actor:#{Postland.my_actor_id()}")
|
||||||
{:noreply, assign(socket, account: account, editing: false, form: form)}
|
{:noreply, assign(socket, account: account, editing: false, form: form)}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ defmodule ActivityPub.HeadersTest do
|
||||||
|
|
||||||
headers = signing_headers(method, url, body, actor_url, private)
|
headers = signing_headers(method, url, body, actor_url, private)
|
||||||
|
|
||||||
verify(method, "/inbox", headers, actor_fetcher)
|
verify(method, "/inbox", headers, body, actor_fetcher)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ defmodule PostlandWeb.UserSessionControllerTest do
|
||||||
# Now do a logged in request and assert on the menu
|
# Now do a logged in request and assert on the menu
|
||||||
conn = get(conn, ~p"/")
|
conn = get(conn, ~p"/")
|
||||||
response = html_response(conn, 200)
|
response = html_response(conn, 200)
|
||||||
assert response =~ user.username
|
|
||||||
assert response =~ ~p"/users/settings"
|
assert response =~ ~p"/users/settings"
|
||||||
assert response =~ ~p"/users/log_out"
|
assert response =~ ~p"/users/log_out"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue