Merge branch 'secure-mode' into 'develop'

Reject requests from specified instances if `authorized_fetch_mode` is enabled

See merge request pleroma/pleroma!3711
This commit is contained in:
lain 2024-05-28 11:22:34 +00:00
commit bef15cde61
14 changed files with 233 additions and 68 deletions

View File

@ -0,0 +1 @@
Add an option to reject certain domains when authorized fetch is enabled.

View File

@ -192,6 +192,7 @@
allow_relay: true,
public: true,
quarantined_instances: [],
rejected_instances: [],
static_dir: "instance/static/",
allowed_post_formats: [
"text/plain",

View File

@ -774,6 +774,18 @@
{"*.quarantined.com", "Reason"}
]
},
%{
key: :rejected_instances,
type: {:list, :tuple},
key_placeholder: "instance",
value_placeholder: "reason",
description:
"List of ActivityPub instances to reject requests from if authorized_fetch_mode is enabled",
suggestions: [
{"rejected.com", "Reason"},
{"*.rejected.com", "Reason"}
]
},
%{
key: :static_dir,
type: :string,

View File

@ -155,6 +155,10 @@
config :pleroma, Pleroma.Web.RichMedia.Helpers, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Uploaders.IPFS, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Web.Plugs.HTTPSecurityPlug, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Web.Plugs.HTTPSignaturePlug, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Web.Plugs.HTTPSignaturePlug,
http_signatures_impl: Pleroma.StubbedHTTPSignaturesMock
peer_module =
if String.to_integer(System.otp_release()) >= 25 do

View File

@ -41,6 +41,7 @@ To add configuration to your config file, you can copy it from the base config.
* `allow_relay`: Permits remote instances to subscribe to all public posts of your instance. This may increase the visibility of your instance.
* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. Note that there is a dependent setting restricting or allowing unauthenticated access to specific resources, see `restrict_unauthenticated` for more details.
* `quarantined_instances`: ActivityPub instances where private (DMs, followers-only) activities will not be send.
* `rejected_instances`: ActivityPub instances to reject requests from if authorized_fetch_mode is enabled.
* `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML).
* `extended_nickname_format`: Set to `true` to use extended local nicknames format (allows underscores/dashes). This will break federation with
older software for theses nicknames.

View File

@ -0,0 +1,4 @@
defmodule Pleroma.HTTPSignaturesAPI do
@callback validate_conn(conn :: Plug.Conn.t()) :: boolean
@callback signature_for_conn(conn :: Plug.Conn.t()) :: map
end

View File

@ -44,8 +44,7 @@ defp remove_suffix(uri, [test | rest]) do
defp remove_suffix(uri, []), do: uri
def fetch_public_key(conn) do
with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn),
{:ok, actor_id} <- key_id_to_actor_id(kid),
with {:ok, actor_id} <- get_actor_id(conn),
{:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do
{:ok, public_key}
else
@ -55,8 +54,7 @@ def fetch_public_key(conn) do
end
def refetch_public_key(conn) do
with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn),
{:ok, actor_id} <- key_id_to_actor_id(kid),
with {:ok, actor_id} <- get_actor_id(conn),
{:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id),
{:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do
{:ok, public_key}
@ -66,6 +64,16 @@ def refetch_public_key(conn) do
end
end
def get_actor_id(conn) do
with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn),
{:ok, actor_id} <- key_id_to_actor_id(kid) do
{:ok, actor_id}
else
e ->
{:error, e}
end
end
def sign(%User{keys: keys} = user, headers) do
with {:ok, private_key, _} <- Keys.keys_from_pem(keys) do
HTTPSignatures.sign(private_key, user.ap_id <> "#main-key", headers)

View File

@ -152,6 +152,7 @@ def features do
def federation do
quarantined = Config.get([:instance, :quarantined_instances], [])
rejected = Config.get([:instance, :rejected_instances], [])
if Config.get([:mrf, :transparency]) do
{:ok, data} = MRF.describe()
@ -171,6 +172,12 @@ def federation do
|> Enum.map(fn {instance, reason} -> {instance, %{"reason" => reason}} end)
|> Map.new()
})
|> Map.put(
:rejected_instances,
rejected
|> Enum.map(fn {instance, reason} -> {instance, %{"reason" => reason}} end)
|> Map.new()
)
else
%{}
end

View File

@ -7,8 +7,18 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
import Plug.Conn
import Phoenix.Controller, only: [get_format: 1, text: 2]
alias Pleroma.Web.ActivityPub.MRF
require Logger
@config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config)
@http_signatures_impl Application.compile_env(
:pleroma,
[__MODULE__, :http_signatures_impl],
HTTPSignatures
)
def init(options) do
options
end
@ -21,7 +31,9 @@ def call(conn, _opts) do
if get_format(conn) in ["json", "activity+json"] do
conn
|> maybe_assign_valid_signature()
|> maybe_assign_actor_id()
|> maybe_require_signature()
|> maybe_filter_requests()
else
conn
end
@ -35,7 +47,7 @@ defp validate_signature(conn, request_target) do
|> put_req_header("(request-target)", request_target)
|> put_req_header("@request-target", request_target)
HTTPSignatures.validate_conn(conn)
@http_signatures_impl.validate_conn(conn)
end
defp validate_signature(conn) do
@ -85,6 +97,16 @@ defp maybe_assign_valid_signature(conn) do
end
end
defp maybe_assign_actor_id(%{assigns: %{valid_signature: true}} = conn) do
adapter = Application.get_env(:http_signatures, :adapter)
{:ok, actor_id} = adapter.get_actor_id(conn)
assign(conn, :actor_id, actor_id)
end
defp maybe_assign_actor_id(conn), do: conn
defp has_signature_header?(conn) do
conn |> get_req_header("signature") |> Enum.at(0, false)
end
@ -92,9 +114,9 @@ defp has_signature_header?(conn) do
defp maybe_require_signature(%{assigns: %{valid_signature: true}} = conn), do: conn
defp maybe_require_signature(%{remote_ip: remote_ip} = conn) do
if Pleroma.Config.get([:activitypub, :authorized_fetch_mode], false) do
if @config_impl.get([:activitypub, :authorized_fetch_mode], false) do
exceptions =
Pleroma.Config.get([:activitypub, :authorized_fetch_mode_exceptions], [])
@config_impl.get([:activitypub, :authorized_fetch_mode_exceptions], [])
|> Enum.map(&InetHelper.parse_cidr/1)
if Enum.any?(exceptions, fn x -> InetCidr.contains?(x, remote_ip) end) do
@ -109,4 +131,29 @@ defp maybe_require_signature(%{remote_ip: remote_ip} = conn) do
conn
end
end
defp maybe_filter_requests(%{halted: true} = conn), do: conn
defp maybe_filter_requests(conn) do
if @config_impl.get([:activitypub, :authorized_fetch_mode], false) and
conn.assigns[:actor_id] do
%{host: host} = URI.parse(conn.assigns.actor_id)
if MRF.subdomain_match?(rejected_domains(), host) do
conn
|> put_status(:unauthorized)
|> halt()
else
conn
end
else
conn
end
end
defp rejected_domains do
@config_impl.get([:instance, :rejected_instances])
|> Pleroma.Web.ActivityPub.MRF.instance_list_from_tuples()
|> Pleroma.Web.ActivityPub.MRF.subdomains_regex()
end
end

View File

@ -67,6 +67,14 @@ test "it returns error when not found user" do
end
end
describe "get_actor_id/1" do
test "it returns actor id" do
ap_id = "https://mastodon.social/users/lambadalambda"
assert Signature.get_actor_id(make_fake_conn(ap_id)) == {:ok, ap_id}
end
end
describe "sign/2" do
test "it returns signature headers" do
user =

View File

@ -3,77 +3,89 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
use Pleroma.Web.ConnCase
use Pleroma.Web.ConnCase, async: true
alias Pleroma.StaticStubbedConfigMock, as: ConfigMock
alias Pleroma.StubbedHTTPSignaturesMock, as: HTTPSignaturesMock
alias Pleroma.Web.Plugs.HTTPSignaturePlug
import Plug.Conn
import Mox
import Phoenix.Controller, only: [put_format: 2]
import Mock
import Plug.Conn
test "it call HTTPSignatures to check validity if the actor sighed it" do
test "it calls HTTPSignatures to check validity if the actor signed it" do
params = %{"actor" => "http://mastodon.example.org/users/admin"}
conn = build_conn(:get, "/doesntmattter", params)
with_mock HTTPSignatures, validate_conn: fn _ -> true end do
conn =
conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
HTTPSignaturesMock
|> expect(:validate_conn, fn _ -> true end)
assert conn.assigns.valid_signature == true
assert conn.halted == false
assert called(HTTPSignatures.validate_conn(:_))
end
conn =
conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == false
end
describe "requires a signature when `authorized_fetch_mode` is enabled" do
setup do
clear_config([:activitypub, :authorized_fetch_mode], true)
params = %{"actor" => "http://mastodon.example.org/users/admin"}
conn = build_conn(:get, "/doesntmattter", params) |> put_format("activity+json")
[conn: conn]
end
test "when signature header is present", %{conn: conn} do
with_mock HTTPSignatures, validate_conn: fn _ -> false end do
conn =
conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{})
test "when signature header is present", %{conn: orig_conn} do
ConfigMock
|> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end)
|> expect(:get, fn [:activitypub, :authorized_fetch_mode_exceptions], [] -> [] end)
assert conn.assigns.valid_signature == false
assert conn.halted == true
assert conn.status == 401
assert conn.state == :sent
assert conn.resp_body == "Request not signed"
assert called(HTTPSignatures.validate_conn(:_))
end
HTTPSignaturesMock
|> expect(:validate_conn, 2, fn _ -> false end)
with_mock HTTPSignatures, validate_conn: fn _ -> true end do
conn =
conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{})
conn =
orig_conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == false
assert called(HTTPSignatures.validate_conn(:_))
end
assert conn.assigns.valid_signature == false
assert conn.halted == true
assert conn.status == 401
assert conn.state == :sent
assert conn.resp_body == "Request not signed"
ConfigMock
|> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end)
HTTPSignaturesMock
|> expect(:validate_conn, fn _ -> true end)
conn =
orig_conn
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == false
end
test "halts the connection when `signature` header is not present", %{conn: conn} do
ConfigMock
|> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end)
|> expect(:get, fn [:activitypub, :authorized_fetch_mode_exceptions], [] -> [] end)
conn = HTTPSignaturePlug.call(conn, %{})
assert conn.assigns[:valid_signature] == nil
assert conn.halted == true
@ -83,22 +95,71 @@ test "halts the connection when `signature` header is not present", %{conn: conn
end
test "exempts specific IPs from `authorized_fetch_mode_exceptions`", %{conn: conn} do
clear_config([:activitypub, :authorized_fetch_mode_exceptions], ["192.168.0.0/24"])
ConfigMock
|> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end)
|> expect(:get, fn [:activitypub, :authorized_fetch_mode_exceptions], [] ->
["192.168.0.0/24"]
end)
|> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end)
with_mock HTTPSignatures, validate_conn: fn _ -> false end do
conn =
conn
|> Map.put(:remote_ip, {192, 168, 0, 1})
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{})
HTTPSignaturesMock
|> expect(:validate_conn, 2, fn _ -> false end)
assert conn.remote_ip == {192, 168, 0, 1}
assert conn.halted == false
assert called(HTTPSignatures.validate_conn(:_))
end
conn =
conn
|> Map.put(:remote_ip, {192, 168, 0, 1})
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> HTTPSignaturePlug.call(%{})
assert conn.remote_ip == {192, 168, 0, 1}
assert conn.halted == false
end
end
test "rejects requests from `rejected_instances` when `authorized_fetch_mode` is enabled" do
ConfigMock
|> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end)
|> expect(:get, fn [:instance, :rejected_instances] ->
[{"mastodon.example.org", "no reason"}]
end)
HTTPSignaturesMock
|> expect(:validate_conn, fn _ -> true end)
conn =
build_conn(:get, "/doesntmattter", %{"actor" => "http://mastodon.example.org/users/admin"})
|> put_req_header(
"signature",
"keyId=\"http://mastodon.example.org/users/admin#main-key"
)
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == true
ConfigMock
|> expect(:get, fn [:activitypub, :authorized_fetch_mode], false -> true end)
|> expect(:get, fn [:instance, :rejected_instances] ->
[{"mastodon.example.org", "no reason"}]
end)
HTTPSignaturesMock
|> expect(:validate_conn, fn _ -> true end)
conn =
build_conn(:get, "/doesntmattter", %{"actor" => "http://allowed.example.org/users/admin"})
|> put_req_header(
"signature",
"keyId=\"http://allowed.example.org/users/admin#main-key"
)
|> put_format("activity+json")
|> HTTPSignaturePlug.call(%{})
assert conn.assigns.valid_signature == true
assert conn.halted == false
end
end

View File

@ -116,6 +116,7 @@ def stub_pipeline do
Mox.stub_with(Pleroma.Web.FederatorMock, Pleroma.Web.Federator)
Mox.stub_with(Pleroma.ConfigMock, Pleroma.Config)
Mox.stub_with(Pleroma.StaticStubbedConfigMock, Pleroma.Test.StaticConfig)
Mox.stub_with(Pleroma.StubbedHTTPSignaturesMock, Pleroma.Test.HTTPSignaturesProxy)
end
def ensure_local_uploader(context) do

View File

@ -0,0 +1,9 @@
defmodule Pleroma.Test.HTTPSignaturesProxy do
@behaviour Pleroma.HTTPSignaturesAPI
@impl true
defdelegate validate_conn(conn), to: HTTPSignatures
@impl true
defdelegate signature_for_conn(conn), to: HTTPSignatures
end

View File

@ -28,6 +28,7 @@
Mox.defmock(Pleroma.ConfigMock, for: Pleroma.Config.Getting)
Mox.defmock(Pleroma.UnstubbedConfigMock, for: Pleroma.Config.Getting)
Mox.defmock(Pleroma.StaticStubbedConfigMock, for: Pleroma.Config.Getting)
Mox.defmock(Pleroma.StubbedHTTPSignaturesMock, for: Pleroma.HTTPSignaturesAPI)
Mox.defmock(Pleroma.LoggerMock, for: Pleroma.Logging)