Compare commits

...

9 commits

Author SHA1 Message Date
e4ed44ba29 update changelog
All checks were successful
ci/woodpecker/push/docs Pipeline was successful
ci/woodpecker/push/publish/4 Pipeline was successful
ci/woodpecker/push/publish/1 Pipeline was successful
ci/woodpecker/push/publish/2 Pipeline was successful
2026-05-04 20:03:30 +01:00
d6df0dd7d9 update httpsignatures 2026-05-04 19:57:43 +01:00
26cb389528 probably best to just not match the not-present case 2026-05-02 18:55:34 +01:00
c50afa78d2 account for multiple host headers, add test for non-default port 2026-05-02 18:54:28 +01:00
c397d29930 remove weird unicode artifact 2026-05-02 16:38:30 +01:00
a5f4cabf14 assert on header itself, ensure host check has been run 2026-05-02 16:33:45 +01:00
c74b0ac96e add unit tests for host header checking 2026-04-30 17:47:45 +01:00
fb37688dd9 mix format 2026-04-30 17:30:47 +01:00
a92e97277b enforce host header matching
on all routes that can be sent via the HTTP signature check, adds
an additional check for the host header. will error if there
is no host header, or if it does not match our configured endpoint
2026-04-30 17:29:00 +01:00
14 changed files with 238 additions and 5 deletions

View file

@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
## 2026.05
### General note
- backup restore instructions very slightly changed but in an important way.
@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- non-federating instances now return a 405 response on inbox `POST`s, matching AP spec
- fixed `GET /api/v1/statuses/:id/context` omitting most local-only posts for authenticated users
- fixed nondeterministic API results in endpoints using GIN indexes; e.g. full-text search
- enforced the host header being present on signatures, and matching our server
### Changed
- our Docker container now sets a default `nofile` `ulimit` to avoid issues on some systems.

View file

@ -0,0 +1,60 @@
# Akkoma: Magically expressive social media
# Copyright © 2022-2026 Akkoma Authors <https://akkoma.dev/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.EnsureHostPlug do
@moduledoc """
Ensures the request has a Host header, and that it matches this server
"""
import Plug.Conn
alias Pleroma.Web.Endpoint
import Phoenix.Controller, only: [text: 2]
def init(options) do
options
end
def call(conn, _) do
case get_req_header(conn, "host") do
[host] ->
handle_host_header(host, conn)
[_host | _more] ->
conn
|> put_status(:bad_request)
|> text("Bad host header")
|> halt()
[] ->
handle_host_header(nil, conn)
end
end
defp handle_host_header(value, conn) when is_binary(value) do
our_uri = Endpoint.struct_url()
default_port = URI.default_port(our_uri.scheme)
expected_host = "#{our_uri.host}:#{our_uri.port}"
if case_insensitive_matches?(value, expected_host) ||
case_insensitive_matches?("#{value}:#{default_port}", expected_host) do
assign(conn, :host_matches, true)
else
conn
|> put_status(:bad_request)
|> text("Host header does not match")
|> halt()
end
end
defp handle_host_header(_, conn) do
conn
|> put_status(:bad_request)
|> text("Host header not present")
|> halt()
end
defp case_insensitive_matches?(a, b) do
String.downcase(a) == String.downcase(b)
end
end

View file

@ -20,7 +20,8 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
conn
end
def call(conn, _opts) do
# ensure host check is run before this without mixing the two up here
def call(%{assigns: %{host_matches: true}} = conn, _opts) do
if get_format(conn) in ["json", "activity+json"] do
conn
|> maybe_assign_valid_signature()
@ -29,6 +30,8 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
end
end
def call(conn, _opts), do: conn
def route_aliases(%{path_info: ["objects", id], query_string: query_string, method: method}) do
ap_id = url(~p[/objects/#{id}])
method = String.downcase(method)

View file

@ -146,11 +146,13 @@ defmodule Pleroma.Web.Router do
end
pipeline :optional_http_signature do
plug(Pleroma.Web.Plugs.EnsureHostPlug)
plug(Pleroma.Web.Plugs.HTTPSignaturePlug)
plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug)
end
pipeline :http_signature do
plug(Pleroma.Web.Plugs.EnsureHostPlug)
plug(Pleroma.Web.Plugs.HTTPSignaturePlug)
plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug)
plug(Pleroma.Web.Plugs.EnsureHTTPSignaturePlug)

View file

@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do
def project do
[
app: :pleroma,
version: version("3.18.1"),
version: version("3.19.0"),
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: Mix.compilers(),

View file

@ -59,7 +59,7 @@
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"http_signatures": {:git, "https://akkoma.dev/AkkomaGang/http_signatures.git", "fa3891074c33b5cb87964450f1226357f44549ae", [branch: "main"]},
"http_signatures": {:git, "https://akkoma.dev/AkkomaGang/http_signatures.git", "a4791e3e8fdf25ed2d7e519548c2a5ade3f9302c", [branch: "main"]},
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.5.52", "18777a36918e3bb91c70f07b69f6a6589d8fa5547a7d210b228d410a2453923f", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "8d75f0f2307e21b53ad96bd746f1806da91859ec0d4a68b203b763da4d5ae567"},

View file

@ -31,6 +31,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
setup do: clear_config([:instance, :federating], true)
setup do: clear_config([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
setup :request_host_header
describe "/relay" do
setup do: clear_config([:instance, :allow_relay], true)
@ -1854,6 +1855,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
build_conn()
|> put_req_header("accept", "application/activity+json")
|> with_request_host_header()
|> assign(:user, other_user)
|> get(object_path)
|> json_response(200)
@ -1878,6 +1880,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
build_conn()
|> put_req_header("accept", "application/activity+json")
|> with_request_host_header()
|> assign(:user, other_user)
|> get(activity_path)
|> json_response(200)

View file

@ -29,6 +29,8 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
clear_config([:mrf_simple, :reject], [])
end
setup :request_host_header
describe "gather_webfinger_links/1" do
test "it returns links" do
user = insert(:user)
@ -449,12 +451,14 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
build_conn()
|> put_req_header("accept", "application/activity+json")
|> with_request_host_header()
|> assign(:user, fetcher)
|> get(object_path)
|> json_response(200)
build_conn()
|> put_req_header("accept", "application/activity+json")
|> with_request_host_header()
|> assign(:user, another_fetcher)
|> get(activity_path)
|> json_response(200)

View file

@ -14,6 +14,7 @@ defmodule Pleroma.Web.Feed.UserControllerTest do
alias Pleroma.Web.Feed.FeedView
setup do: clear_config([:static_fe, :enabled], false)
setup :request_host_header
describe "feed" do
setup do: clear_config([:feed])

View file

@ -21,6 +21,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do
end
setup do: clear_config([:static_fe, :enabled], false)
setup :request_host_header
describe "Mastodon compatibility routes" do
setup %{conn: conn} do

View file

@ -0,0 +1,118 @@
defmodule Pleroma.Web.Plugs.EnsureHostPlugTest do
use Pleroma.Web.ConnCase, async: false
alias Pleroma.Web.Plugs.EnsureHostPlug
import Plug.Conn
import Mock
defp put_host_header(conn, host) do
%{
conn
| req_headers: [
{"host", host} | conn.req_headers
]
}
end
describe "requires a host header that matches our server" do
setup do
conn = build_conn(:get, "/doesntmatter")
[conn: conn]
end
test "rejects a request where no host value is present", %{conn: conn} do
conn =
conn
|> put_host_header(nil)
|> EnsureHostPlug.call(%{})
assert conn.halted == true
assert conn.status == 400
assert conn.state == :sent
assert conn.resp_body == "Host header not present"
end
test "rejects a request where the host value does not match", %{conn: conn} do
conn =
conn
|> put_host_header("oops-not-us.info")
|> EnsureHostPlug.call(%{})
assert conn.halted == true
assert conn.status == 400
assert conn.state == :sent
assert conn.resp_body == "Host header does not match"
end
test "rejects a request where the port value does not match", %{conn: conn} do
host = Pleroma.Web.Endpoint.host()
conn =
conn
|> put_host_header("#{host}:9")
|> EnsureHostPlug.call(%{})
assert conn.halted == true
assert conn.status == 400
assert conn.state == :sent
assert conn.resp_body == "Host header does not match"
end
test "accepts a request where the hostname matches and there is no port", %{conn: conn} do
# this test actually needs a mock as our test server does not run on the default http port
url = Pleroma.Web.Endpoint.struct_url()
with_mock Pleroma.Web.Endpoint, struct_url: fn -> %{url | port: 80} end do
conn =
conn
|> put_host_header(url.host)
|> EnsureHostPlug.call(%{})
assert conn.halted == false
end
end
test "rejects a request where the hostname matches and there is no port, and we do not run on the default",
%{conn: conn} do
url = Pleroma.Web.Endpoint.struct_url()
conn =
conn
|> put_host_header(url.host)
|> EnsureHostPlug.call(%{})
assert conn.halted == true
assert conn.status == 400
assert conn.state == :sent
assert conn.resp_body == "Host header does not match"
end
test "accepts a request where the hostname matches, with a port", %{conn: conn} do
%{host: host, port: port} = Pleroma.Web.Endpoint.struct_url()
conn =
conn
|> put_host_header("#{host}:#{port}")
|> EnsureHostPlug.call(%{})
assert conn.halted == false
end
test "rejects a request with multiple host headers", %{conn: conn} do
url = Pleroma.Web.Endpoint.struct_url()
conn =
conn
|> put_host_header(url.host)
|> put_host_header("example.com")
|> EnsureHostPlug.call(%{})
assert conn.halted == true
assert conn.status == 400
assert conn.state == :sent
assert conn.resp_body == "Bad host header"
end
end
end

View file

@ -53,6 +53,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
conn =
conn
|> assign(:host_matches, true)
|> put_req_header(
"signature",
"keyId=\"#{user.signing_key.key_id}\""
@ -71,7 +72,11 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest 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 =
build_conn(:get, "/doesntmattter", params)
|> put_format("activity+json")
|> assign(:host_matches, true)
[conn: conn]
end
@ -138,6 +143,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
test "fakes success on gone key when receiving Delete" do
build_conn(:post, "/inbox", %{"type" => "Delete"})
|> put_format("activity+json")
|> assign(:host_matches, true)
|> assign(:gone_signature_key, true)
|> put_req_header(
"signature",
@ -150,6 +156,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
test "fails on gone key for non-Delete" do
conn =
build_conn(:post, "/inbox", %{"type" => "Note"})
|> assign(:host_matches, true)
|> put_format("activity+json")
|> assign(:gone_signature_key, true)
|> put_req_header(
@ -165,6 +172,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
test "fakes accept for POST on rejected keys", %{user: user} do
build_conn(:post, "/inbox", %{"type" => "Note"})
|> assign(:host_matches, true)
|> put_format("activity+json")
|> assign(:rejected_key_id, true)
|> put_req_header(
@ -177,6 +185,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
test "fakes not found for GET on rejected keys", %{user: user} do
build_conn(:get, "/doesntmattter", %{"user" => user.ap_id})
|> assign(:host_matches, true)
|> put_format("activity+json")
|> assign(:rejected_key_id, true)
|> put_req_header(
@ -186,4 +195,19 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
|> HTTPSignaturePlug.call(%{})
|> response(404)
end
test "does not assign anything when a host match has not been run", %{user: user} do
conn =
build_conn(:get, "/doesntmattter", %{"user" => user.ap_id})
|> assign(:host_matches, false)
|> put_format("activity+json")
|> put_req_header(
"signature",
"keyId=\"#{user.signing_key.key_id}\""
)
|> HTTPSignaturePlug.call(%{})
refute Map.has_key?(conn.assigns, :valid_signature)
refute Map.has_key?(conn.assigns, :signature_user)
end
end

View file

@ -16,6 +16,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do
setup_all do: clear_config([:static_fe, :enabled], true)
setup do: clear_config([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
setup :request_host_header
setup %{conn: conn} do
conn = put_req_header(conn, "accept", "text/html")

View file

@ -59,6 +59,21 @@ defmodule Pleroma.Web.ConnCase do
[conn: conn]
end
defp with_request_host_header(conn) do
uri = Pleroma.Web.Endpoint.struct_url()
%{
conn
| req_headers: [
{"host", "#{uri.host}:#{uri.port}"} | conn.req_headers
]
}
end
defp request_host_header(%{conn: conn}) do
[conn: with_request_host_header(conn)]
end
defp empty_json_response(conn) do
body = response(conn, 204)
response_content_type(conn, :json)