aprs.me/lib/aprsme_web/plugs/remote_ip.ex
Graham McIntire 0f2195ef9d
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 2s
security: CSP nonces, RemoteIp CIDR gating, rate limiting, NetworkPolicies, deadlock fixes
- Content-Security-Policy: nonce-based per-request plug replacing unsafe-inline scripts
- RemoteIp: CIDR-based trust gating via InetCidr, skips forwarded headers from untrusted peers
- Rate limiting: auth pipeline (20/min), LiveView event handlers, existing mobile channel limits
- NetworkPolicy: 4 k8s policies (web ingress, cluster, metrics, egress) for least-privilege networking
- PartitionManager: deadlock retry with exponential backoff in drop_partition
- Tests: reduced parallelism (max_cases 4), packets_test async:false to prevent trigger contention
- k8s: APRS_PASSWORD -> APRS_PASSCODE secretRef, vendor/aprs submodule hardened
2026-07-26 14:04:56 -05:00

124 lines
3 KiB
Elixir

defmodule AprsmeWeb.Plugs.RemoteIp do
@moduledoc """
Plug that extracts the real client IP from Cloudflare or proxy headers
and sets `conn.remote_ip` so downstream logging shows the actual client address.
Header priority:
1. `CF-Connecting-IP` (Cloudflare)
2. `X-Forwarded-For` (first IP in the chain)
## Trust model
Forwarded headers are only respected when the **TCP peer IP** (the actual
connecting IP, before any header rewriting) is within a configured list of
trusted proxy CIDRs. If the connecting peer is not trusted, the forwarded
headers are ignored and the TCP peer IP is used as-is.
Configure via Application environment or plug options:
config :aprsme, AprsmeWeb.Plugs.RemoteIp,
trusted_proxies: ["10.0.0.0/8", "172.16.0.0/12"]
Or inline in the endpoint pipeline:
plug AprsmeWeb.Plugs.RemoteIp, trusted_proxies: ["103.21.244.0/22"]
When no trusted proxies are configured, **no peer is trusted** and all
forwarded headers are ignored — the safest default.
"""
@behaviour Plug
import Plug.Conn
require Logger
@default_trusted_proxies []
@impl true
def init(opts) do
trusted_proxies =
Keyword.get(opts, :trusted_proxies) ||
Application.get_env(:aprsme, __MODULE__, [])[:trusted_proxies] ||
@default_trusted_proxies
cidrs =
trusted_proxies
|> Enum.map(&InetCidr.parse_cidr/1)
|> Enum.filter(fn
{:ok, _cidr} ->
true
{:error, reason} ->
Logger.warning("Invalid CIDR in RemoteIp trusted_proxies: #{inspect(reason)}")
false
end)
|> Enum.map(fn {:ok, cidr} -> cidr end)
%{trusted_proxies: cidrs}
end
@impl true
def call(conn, %{trusted_proxies: cidrs}) do
peer_ip = conn.remote_ip
conn =
if trusted_peer?(peer_ip, cidrs) do
case get_client_ip(conn) do
{:ok, ip} -> %{conn | remote_ip: ip}
:error -> conn
end
else
conn
end
user_agent =
case get_req_header(conn, "user-agent") do
[ua | _] -> ua
[] -> "-"
end
Logger.metadata(
remote_ip: conn.remote_ip |> :inet.ntoa() |> to_string(),
user_agent: user_agent
)
conn
end
defp trusted_peer?(peer_ip, cidrs) do
Enum.any?(cidrs, fn cidr -> InetCidr.contains?(cidr, peer_ip) end)
end
defp get_client_ip(conn) do
with :error <- parse_cf_header(conn) do
parse_forwarded_for(conn)
end
end
defp parse_cf_header(conn) do
case get_req_header(conn, "cf-connecting-ip") do
[ip_str | _] -> parse_ip(ip_str)
[] -> :error
end
end
defp parse_forwarded_for(conn) do
case get_req_header(conn, "x-forwarded-for") do
[value | _] ->
value
|> String.split(",")
|> List.first()
|> parse_ip()
[] ->
:error
end
end
defp parse_ip(ip_str) do
case ip_str |> String.trim() |> String.to_charlist() |> :inet.parse_address() do
{:ok, ip} -> {:ok, ip}
{:error, _} -> :error
end
end
end