Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

View file

@ -0,0 +1,170 @@
defmodule TarakanWeb.Plugs.ClientIp do
@moduledoc """
Rewrites `conn.remote_ip` from a trusted reverse proxy's forwarded headers.
Only applies when:
1. `:trusted_proxies` is configured (CIDR strings or IPs), and
2. the direct TCP peer is inside that set.
Without trusted proxies configured, the peer address is left untouched so
clients cannot spoof `X-Forwarded-For` against a publicly exposed app.
"""
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
case trusted_proxies() do
[] ->
conn
proxies ->
if trusted_ip?(conn.remote_ip, proxies) do
case client_ip_from_headers(forwarded_values(conn), proxies) do
ip when is_tuple(ip) -> %{conn | remote_ip: ip}
nil -> conn
end
else
conn
end
end
end
@doc """
Resolves the per-IP rate-limit bucket key for a Plug.Conn.
IPv4 clients bucket by their full address. IPv6 clients bucket by their
/64 prefix so rotating through the lower 64 bits of a delegated prefix
cannot mint unlimited fresh rate-limit buckets.
"""
def remote_ip_bucket(%Plug.Conn{} = conn) do
case normalize_ip(conn.remote_ip) do
{a, b, c, d} -> {:v4, a, b, c, d}
{a, b, c, d, _e, _f, _g, _h} -> {:v6, a, b, c, d}
end
rescue
_error -> :unavailable
end
@doc "Resolves a client IP string for rate limiting from a Plug.Conn."
def remote_ip_string(%Plug.Conn{} = conn) do
conn.remote_ip
|> normalize_ip()
|> :inet.ntoa()
|> to_string()
rescue
_error -> "unavailable"
end
@doc false
def trusted_ip?(ip, proxies) when is_tuple(ip) and is_list(proxies) do
normalized = normalize_ip(ip)
Enum.any?(proxies, &ip_in_proxy?(normalized, &1))
end
def trusted_ip?(_ip, _proxies), do: false
@doc """
Selects the real client IP from forwarded header values.
`values` is the list of raw `X-Forwarded-For` header strings (each may itself
be comma-separated). Proxies append the peer they received from, so the
rightmost entry is closest to us; we walk from the right, skip every trusted
proxy, and take the first untrusted hop. That address is the furthest one we
can still attribute to a hop we trust - anything to its left is
client-supplied and spoofable. Returns an IP tuple, or `nil` when nothing
parses.
"""
def client_ip_from_headers(values, proxies) when is_list(values) and is_list(proxies) do
ips =
values
|> Enum.flat_map(&String.split(&1, ",", trim: true))
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.map(&parse_ip/1)
|> Enum.reject(&is_nil/1)
case ips do
[] ->
nil
_ ->
rightmost_untrusted =
ips
|> Enum.reverse()
|> Enum.drop_while(&trusted_ip?(&1, proxies))
|> List.first()
# When every hop is a trusted proxy, the leftmost entry is the best
# available guess at the original client.
normalize_ip(rightmost_untrusted || List.first(ips))
end
end
defp forwarded_values(conn) do
Enum.flat_map(forwarded_headers(), &Plug.Conn.get_req_header(conn, &1))
end
defp parse_ip(nil), do: nil
defp parse_ip(value) do
value = value |> String.trim_leading("[") |> String.trim_trailing("]")
case :inet.parse_address(String.to_charlist(value)) do
{:ok, ip} -> ip
{:error, _reason} -> nil
end
end
# IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) arrive as 8-tuples on a
# dual-stack (`::`) listener; fold them to the plain IPv4 tuple so trusted
# proxy CIDRs match and rate-limit keys stay stable across families.
defp normalize_ip({0, 0, 0, 0, 0, 0xFFFF, g, h}) do
{Bitwise.bsr(g, 8), Bitwise.band(g, 0xFF), Bitwise.bsr(h, 8), Bitwise.band(h, 0xFF)}
end
defp normalize_ip(ip), do: ip
defp ip_in_proxy?(ip, %{} = proxy), do: cidr_contains?(proxy, ip)
defp trusted_proxies do
Application.get_env(:tarakan, :trusted_proxies, [])
end
defp forwarded_headers do
Application.get_env(:tarakan, :remote_ip_headers, ["x-forwarded-for"])
end
# Minimal IPv4/IPv6 CIDR matcher. Proxies are pre-parsed at config load.
defp cidr_contains?(%{family: family, net: net, mask: mask}, ip)
when tuple_size(ip) == tuple_size(net) do
ip_int = ip_to_integer(ip)
net_int = ip_to_integer(net)
host_bits = if family == :inet, do: 32, else: 128
shift = host_bits - mask
if shift >= 0 do
Bitwise.bsr(ip_int, shift) == Bitwise.bsr(net_int, shift)
else
false
end
end
defp cidr_contains?(_proxy, _ip), do: false
defp ip_to_integer({a, b, c, d}),
do:
Bitwise.bor(
Bitwise.bor(Bitwise.bsl(a, 24), Bitwise.bsl(b, 16)),
Bitwise.bor(Bitwise.bsl(c, 8), d)
)
defp ip_to_integer({a, b, c, d, e, f, g, h}) do
[a, b, c, d, e, f, g, h]
|> Enum.reduce(0, fn part, acc -> Bitwise.bor(Bitwise.bsl(acc, 16), part) end)
end
end