From ea886dc36b3727d7bd22ede1bdb90de3dfb8668e Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 12 May 2026 11:37:01 +0200 Subject: [PATCH] EnsureHostMatchesPlug: Ensure Host header matches instance URI --- changelog.d/host-header-verification.security | 1 + .../web/plugs/ensure_host_matches_plug.ex | 68 +++++++++++++++++++ lib/pleroma/web/router.ex | 1 + 3 files changed, 70 insertions(+) create mode 100644 changelog.d/host-header-verification.security create mode 100644 lib/pleroma/web/plugs/ensure_host_matches_plug.ex diff --git a/changelog.d/host-header-verification.security b/changelog.d/host-header-verification.security new file mode 100644 index 000000000..11a0784b2 --- /dev/null +++ b/changelog.d/host-header-verification.security @@ -0,0 +1 @@ +Ensure Host header is present and matches instance URI diff --git a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex new file mode 100644 index 000000000..87c4c0531 --- /dev/null +++ b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlug do + @moduledoc "Ensures Host header matches instance" + + alias Pleroma.Web.Endpoint + + import Plug.Conn + + def init(options), do: options + + @spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() + def call(%Plug.Conn{assigns: %{valid_signature: true}} = conn, _opts) do + # Host header is scheme-less, URI.parse needs the // + host_header = get_req_header(conn, "host") + host_uri = URI.parse("//#{host_header}") + instance_uri = URI.parse(Endpoint.url()) + instance_scheme_port = URI.default_port(instance_uri.scheme) + + case host_header do + [host] -> + cond do + host == "" -> + resp(conn, 400, "Host header not provided") |> halt() + + true -> + if host_matches?(host_uri, instance_uri, instance_scheme_port), + do: assign(conn, :valid_host_header, true), + else: resp(conn, 400, "Host header does not match this instance") |> halt() + end + + [_head | _rest] -> + conn + |> resp(400, "More than one Host header provided") + |> halt() + + [] -> + conn + |> resp(400, "Host header not provided") + |> halt() + end + end + + # Host header may not be provided, but signature verification failed anyway + def call(conn, _opts), do: conn + + defp case_insensitive_compare(checked, authority) do + String.downcase(checked) == String.downcase(authority) + end + + # Host header did not provide port + # Host header is scheme-less, URI.parse does not provide default port + defp host_matches?(%URI{host: req_host, port: nil}, %URI{host: instance_host}, _), + do: case_insensitive_compare(req_host, instance_host) + + # Host header provided a port, reverse proxy configuration (port cannot match Endpoint port) + # Both port 80 and 443 are valid based on Endpoint configuration + defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host}, port), + do: case_insensitive_compare(req_host, instance_host) + + # Host header provided port, configuration without reverse proxy (port matches Endpoint port) + defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host, port: port}, _), + do: case_insensitive_compare(req_host, instance_host) + + defp host_matches?(_, _, _), do: false +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index c91ca8c97..813c5c482 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -216,6 +216,7 @@ defmodule Pleroma.Web.Router do pipeline :http_signature do plug(Pleroma.Web.Plugs.HTTPSignaturePlug) plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) + plug(Pleroma.Web.Plugs.EnsureHostMatchesPlug) end pipeline :inbox_guard do