IPv6 segments were being printed in decimal (e.g. 10772:1985:1024:45::1 for 2a14:7c1:400:2d::1), which made request_id logs unreadable.
184 lines
5.4 KiB
Elixir
184 lines
5.4 KiB
Elixir
defmodule MicrowavepropWeb.Plugs.RemoteIp do
|
|
@moduledoc """
|
|
Extracts the client IP from `Cf-Connecting-Ip` / `X-Forwarded-For` headers,
|
|
but only when the immediate peer (`conn.remote_ip`) is a trusted proxy.
|
|
|
|
Deployment: the app runs as pods in the k8s `prop` namespace behind the
|
|
cluster ingress, which itself sits behind Cloudflare. The only hosts that
|
|
should ever terminate TCP to a pod are ingress replicas on the cluster
|
|
pod network, so we only honour forwarded-IP headers when the raw socket
|
|
peer falls inside a configured CIDR list.
|
|
|
|
Trusted proxies are configured via `config :microwaveprop, :trusted_proxies`
|
|
(a list of CIDR strings). The default list covers loopback and the RFC1918
|
|
/ ULA ranges used by k8s cluster pod networks. `config/runtime.exs` reads
|
|
`TRUSTED_PROXY_CIDRS` (comma-separated) in production for tuning without
|
|
a rebuild.
|
|
|
|
Rationale: without this guard any client that reaches a pod directly
|
|
(cluster-internal, `kubectl port-forward`, a misconfigured Service) could
|
|
spoof `Cf-Connecting-Ip` to set `conn.remote_ip`, which flows into session
|
|
storage and logs.
|
|
"""
|
|
|
|
@behaviour Plug
|
|
|
|
import Bitwise
|
|
|
|
require Logger
|
|
|
|
@ip_headers ["cf-connecting-ip", "x-forwarded-for"]
|
|
|
|
# RFC1918 + CGNAT + loopback + link-local + IPv6 loopback + ULA.
|
|
# Matches typical k8s pod/service CIDRs and the ingress mesh.
|
|
@default_trusted_proxies [
|
|
"127.0.0.0/8",
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
"100.64.0.0/10",
|
|
"169.254.0.0/16",
|
|
"::1/128",
|
|
"fc00::/7",
|
|
"fe80::/10"
|
|
]
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
cidrs =
|
|
Keyword.get_lazy(opts, :trusted_proxies, fn ->
|
|
Application.get_env(:microwaveprop, :trusted_proxies, @default_trusted_proxies)
|
|
end)
|
|
|
|
parsed =
|
|
cidrs
|
|
|> Enum.map(&parse_cidr/1)
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
%{trusted_proxies: parsed}
|
|
end
|
|
|
|
@impl true
|
|
def call(conn, %{trusted_proxies: trusted_proxies}) do
|
|
# Bandit's dual-stack listener serves IPv4 connections as IPv4-mapped
|
|
# IPv6 tuples (`::ffff:a.b.c.d`). Normalize those to plain IPv4 so
|
|
# the trust check matches our IPv4 CIDRs and downstream logs see
|
|
# consistent IPv4 tuples instead of `0:0:0:0:0:65535:...` forms.
|
|
conn = %{conn | remote_ip: normalize_ip(conn.remote_ip)}
|
|
|
|
conn =
|
|
if peer_trusted?(conn.remote_ip, trusted_proxies) do
|
|
case extract_forwarded_ip(conn) do
|
|
nil -> conn
|
|
ip -> %{conn | remote_ip: ip}
|
|
end
|
|
else
|
|
conn
|
|
end
|
|
|
|
Logger.metadata(
|
|
remote_ip: format_ip(conn.remote_ip),
|
|
method: conn.method,
|
|
request_path: conn.request_path
|
|
)
|
|
|
|
conn
|
|
end
|
|
|
|
# Collapse an IPv4-mapped IPv6 tuple (`::ffff:a.b.c.d`) to a plain IPv4
|
|
# tuple so the rest of the pipeline sees one canonical form.
|
|
defp normalize_ip({0, 0, 0, 0, 0, 0xFFFF, hi, lo}) do
|
|
{hi >>> 8, hi &&& 0xFF, lo >>> 8, lo &&& 0xFF}
|
|
end
|
|
|
|
defp normalize_ip(ip), do: ip
|
|
|
|
defp extract_forwarded_ip(conn) do
|
|
Enum.find_value(@ip_headers, fn header ->
|
|
case Plug.Conn.get_req_header(conn, header) do
|
|
[value | _] ->
|
|
value
|
|
|> String.split(",")
|
|
|> hd()
|
|
|> String.trim()
|
|
|> parse_ip()
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp parse_ip(str) do
|
|
case :inet.parse_address(String.to_charlist(str)) do
|
|
{:ok, ip} -> ip
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# Parses "10.0.0.0/8" or "fc00::/7" into {ip_integer, mask_integer, bit_width}.
|
|
# Invalid entries are logged and dropped at init-time.
|
|
defp parse_cidr(cidr) when is_binary(cidr) do
|
|
case String.split(cidr, "/", parts: 2) do
|
|
[ip_str, prefix_str] ->
|
|
with {prefix, ""} <- Integer.parse(prefix_str),
|
|
{:ok, ip} <- :inet.parse_address(String.to_charlist(ip_str)),
|
|
bit_width = bit_width_for(ip),
|
|
true <- prefix >= 0 and prefix <= bit_width do
|
|
mask = cidr_mask(prefix, bit_width)
|
|
{ip_to_integer(ip), mask, bit_width}
|
|
else
|
|
_ ->
|
|
Logger.warning("RemoteIp: could not parse CIDR #{inspect(cidr)}")
|
|
nil
|
|
end
|
|
|
|
_ ->
|
|
Logger.warning("RemoteIp: CIDR missing /prefix: #{inspect(cidr)}")
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp parse_cidr(_), do: nil
|
|
|
|
defp bit_width_for(ip) when tuple_size(ip) == 4, do: 32
|
|
defp bit_width_for(ip) when tuple_size(ip) == 8, do: 128
|
|
|
|
# Build a prefix-bit mask: e.g. prefix=24, bit_width=32 -> 0xFFFFFF00.
|
|
defp cidr_mask(0, _bit_width), do: 0
|
|
|
|
defp cidr_mask(prefix, bit_width) do
|
|
host_bits = bit_width - prefix
|
|
all_ones = (1 <<< bit_width) - 1
|
|
all_ones - ((1 <<< host_bits) - 1)
|
|
end
|
|
|
|
defp peer_trusted?(peer_ip, trusted) when tuple_size(peer_ip) in [4, 8] do
|
|
peer_width = bit_width_for(peer_ip)
|
|
peer_int = ip_to_integer(peer_ip)
|
|
|
|
Enum.any?(trusted, fn {cidr_int, mask, bit_width} ->
|
|
bit_width == peer_width and band(peer_int, mask) == band(cidr_int, mask)
|
|
end)
|
|
end
|
|
|
|
defp peer_trusted?(_, _), do: false
|
|
|
|
defp ip_to_integer({a, b, c, d}) do
|
|
(a <<< 24) + (b <<< 16) + (c <<< 8) + d
|
|
end
|
|
|
|
defp ip_to_integer({a, b, c, d, e, f, g, h}) do
|
|
(a <<< 112) + (b <<< 96) + (c <<< 80) + (d <<< 64) +
|
|
(e <<< 48) + (f <<< 32) + (g <<< 16) + h
|
|
end
|
|
|
|
defp format_ip(ip) when tuple_size(ip) in [4, 8] do
|
|
case :inet.ntoa(ip) do
|
|
{:error, _} -> "unknown"
|
|
charlist -> List.to_string(charlist)
|
|
end
|
|
end
|
|
|
|
defp format_ip(_), do: "unknown"
|
|
end
|