Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
111
lib/tarakan_web/plugs/api_rate_limit.ex
Normal file
111
lib/tarakan_web/plugs/api_rate_limit.ex
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
defmodule TarakanWeb.Plugs.ApiRateLimit do
|
||||
@moduledoc """
|
||||
Applies independent IP, account, token, and mutation limits to API traffic.
|
||||
|
||||
Every request counts against the per-IP bucket, including Bearer traffic:
|
||||
a request carrying a bogus `Authorization: Bearer …` token must not dodge
|
||||
throttling by failing authentication before the actor buckets run. Valid
|
||||
Bearer requests additionally count against the actor buckets after auth
|
||||
(the double count is deliberate; both buckets share the request limit).
|
||||
|
||||
IPv6 clients bucket by their /64 prefix. Platform admins and moderators
|
||||
are not rate-limited (mass registration / operational tooling).
|
||||
|
||||
Authenticated actors get `@actor_multiplier` × the configured limits. The
|
||||
IP bucket stays at the base limit, so the actor ceiling rises without
|
||||
raising the network one.
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller, only: [json: 2]
|
||||
|
||||
alias Tarakan.RateLimiter
|
||||
alias TarakanWeb.Plugs.ClientIp
|
||||
|
||||
@defaults [request_limit: 120, mutation_limit: 20, window_seconds: 60]
|
||||
@actor_multiplier 4
|
||||
@unlimited_roles ~w(admin moderator)
|
||||
|
||||
def init(opts) do
|
||||
configured = Application.get_env(:tarakan, __MODULE__, [])
|
||||
@defaults |> Keyword.merge(configured) |> Keyword.merge(opts)
|
||||
end
|
||||
|
||||
def call(conn, opts) do
|
||||
mode = Keyword.fetch!(opts, :mode)
|
||||
|
||||
# Admins/moderators (after auth) are unlimited for operational mass import.
|
||||
if unlimited_actor?(conn) do
|
||||
conn
|
||||
else
|
||||
request_limit = Keyword.fetch!(opts, :request_limit)
|
||||
mutation_limit = Keyword.fetch!(opts, :mutation_limit)
|
||||
window_seconds = Keyword.fetch!(opts, :window_seconds)
|
||||
|
||||
# Pipelines that serve different traffic get separate namespaces, so a
|
||||
# hotlinked public asset cannot spend the budget a contributor's API
|
||||
# client needs from the same network.
|
||||
bucket = Keyword.get(opts, :bucket, :api_ip)
|
||||
|
||||
checks =
|
||||
case mode do
|
||||
:ip -> [{{bucket, remote_ip_bucket(conn)}, request_limit}]
|
||||
:actor -> actor_checks(conn, request_limit, mutation_limit)
|
||||
end
|
||||
|
||||
case Enum.find_value(checks, &limited?(&1, window_seconds)) do
|
||||
nil -> conn
|
||||
retry_after -> reject(conn, retry_after)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp unlimited_actor?(conn) do
|
||||
case conn.assigns[:current_scope] do
|
||||
%{platform_role: role} when role in @unlimited_roles -> true
|
||||
%{account: %{platform_role: role}} when role in @unlimited_roles -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp actor_checks(conn, request_limit, mutation_limit) do
|
||||
scope = conn.assigns[:current_scope]
|
||||
account_id = scope && scope.account_id
|
||||
token_id = scope && scope.token_id
|
||||
|
||||
{request_limit, mutation_limit} =
|
||||
{request_limit * @actor_multiplier, mutation_limit * @actor_multiplier}
|
||||
|
||||
base = [
|
||||
{{:api_account, account_id || :anonymous}, request_limit},
|
||||
{{:api_token, token_id || :session}, request_limit}
|
||||
]
|
||||
|
||||
if conn.method in ~w(POST PUT PATCH DELETE) do
|
||||
[
|
||||
{{:api_account_mutation, account_id || :anonymous}, mutation_limit},
|
||||
{{:api_token_mutation, token_id || :session}, mutation_limit}
|
||||
| base
|
||||
]
|
||||
else
|
||||
base
|
||||
end
|
||||
end
|
||||
|
||||
defp limited?({key, limit}, window_seconds) do
|
||||
case RateLimiter.check(key, limit, window_seconds) do
|
||||
:ok -> nil
|
||||
{:error, _reason, retry_after} -> retry_after
|
||||
end
|
||||
end
|
||||
|
||||
defp reject(conn, retry_after) do
|
||||
conn
|
||||
|> put_resp_header("retry-after", Integer.to_string(retry_after))
|
||||
|> put_status(:too_many_requests)
|
||||
|> json(%{error: "rate limit exceeded", retry_after: retry_after})
|
||||
|> halt()
|
||||
end
|
||||
|
||||
defp remote_ip_bucket(conn), do: ClientIp.remote_ip_bucket(conn)
|
||||
end
|
||||
170
lib/tarakan_web/plugs/client_ip.ex
Normal file
170
lib/tarakan_web/plugs/client_ip.ex
Normal 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
|
||||
43
lib/tarakan_web/plugs/code_browser_headers.ex
Normal file
43
lib/tarakan_web/plugs/code_browser_headers.ex
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
defmodule TarakanWeb.Plugs.CodeBrowserHeaders do
|
||||
@moduledoc false
|
||||
|
||||
import Plug.Conn, only: [put_resp_header: 3]
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
cond do
|
||||
default_code_path?(conn.path_info) ->
|
||||
put_resp_header(conn, "cache-control", "no-store")
|
||||
|
||||
code_browser_path?(conn.path_info) ->
|
||||
conn
|
||||
|> put_resp_header("cache-control", "no-store")
|
||||
|> put_resp_header("x-robots-tag", "noindex, nofollow")
|
||||
|
||||
true ->
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
# Repository roots: hosted /owner/name and remote /host/owner/name.
|
||||
defp default_code_path?([first, _name]), do: repository_segment?(first)
|
||||
defp default_code_path?(["findings", _finding_ref, "code"]), do: false
|
||||
defp default_code_path?([first, _owner, _name]), do: repository_segment?(first)
|
||||
defp default_code_path?(_path), do: false
|
||||
|
||||
defp code_browser_path?(["findings", _finding_ref, "code"]), do: true
|
||||
defp code_browser_path?([first, _second, "code" | _rest]), do: repository_segment?(first)
|
||||
|
||||
defp code_browser_path?([first, _second, _third, "code" | _rest]),
|
||||
do: repository_segment?(first)
|
||||
|
||||
defp code_browser_path?(_path), do: false
|
||||
|
||||
# A repository path leads with a host (domain or legacy slug) or an
|
||||
# account handle; fixed-route prefixes are reserved handles and excluded.
|
||||
defp repository_segment?(segment) do
|
||||
Tarakan.Hosts.host_segment?(segment) or
|
||||
not Tarakan.Accounts.Account.reserved_handle?(segment)
|
||||
end
|
||||
end
|
||||
58
lib/tarakan_web/plugs/code_browser_rate_limit.ex
Normal file
58
lib/tarakan_web/plugs/code_browser_rate_limit.ex
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule TarakanWeb.Plugs.CodeBrowserRateLimit do
|
||||
@moduledoc """
|
||||
Per-IP budget for browser traffic, which includes the code browser.
|
||||
|
||||
Anonymous code browsing spawns git subprocesses against local mirrors and
|
||||
can enqueue outbound mirror fetches, so each client IP gets a generous
|
||||
request budget (default 300/min). IPv6 clients bucket by their /64
|
||||
prefix. Platform admins and moderators are exempt (operational tooling);
|
||||
the plug runs after the session scope is fetched so the exemption can see
|
||||
the current role.
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
alias Tarakan.RateLimiter
|
||||
alias TarakanWeb.Plugs.ClientIp
|
||||
|
||||
@defaults [limit: 300, window_seconds: 60]
|
||||
@unlimited_roles ~w(admin moderator)
|
||||
|
||||
@doc false
|
||||
def init(opts) do
|
||||
configured = Application.get_env(:tarakan, __MODULE__, [])
|
||||
@defaults |> Keyword.merge(configured) |> Keyword.merge(opts)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def call(conn, opts) do
|
||||
if unlimited_actor?(conn) do
|
||||
conn
|
||||
else
|
||||
limit = Keyword.fetch!(opts, :limit)
|
||||
window_seconds = Keyword.fetch!(opts, :window_seconds)
|
||||
key = {:code_browser_ip, ClientIp.remote_ip_bucket(conn)}
|
||||
|
||||
case RateLimiter.check(key, limit, window_seconds) do
|
||||
:ok -> conn
|
||||
{:error, _reason, retry_after} -> reject(conn, retry_after)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp unlimited_actor?(conn) do
|
||||
case conn.assigns[:current_scope] do
|
||||
%{platform_role: role} when role in @unlimited_roles -> true
|
||||
%{account: %{platform_role: role}} when role in @unlimited_roles -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp reject(conn, retry_after) do
|
||||
conn
|
||||
|> put_resp_header("retry-after", Integer.to_string(retry_after))
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(429, "too many requests")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
45
lib/tarakan_web/plugs/forwarded_proto.ex
Normal file
45
lib/tarakan_web/plugs/forwarded_proto.ex
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
defmodule TarakanWeb.Plugs.ForwardedProto do
|
||||
@moduledoc """
|
||||
Rewrites `conn.scheme` from a trusted reverse proxy's `X-Forwarded-Proto`.
|
||||
|
||||
Only applies when:
|
||||
|
||||
1. `:trusted_proxies` is configured (CIDR strings or IPs), and
|
||||
2. the direct TCP peer is inside that set.
|
||||
|
||||
Must run before `Plug.SSL`: behind a TLS-terminating proxy every request
|
||||
arrives as plain HTTP, so `Plug.SSL` can only tell external HTTPS from HTTP
|
||||
through this header - and the header is only meaningful from a peer we
|
||||
trust. Untrusted peers keep the real scheme, so a direct client cannot
|
||||
suppress the HTTPS redirect or HSTS by forging `X-Forwarded-Proto`.
|
||||
"""
|
||||
|
||||
@behaviour Plug
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
alias TarakanWeb.Plugs.ClientIp
|
||||
|
||||
@impl true
|
||||
def init(opts), do: opts
|
||||
|
||||
@impl true
|
||||
def call(conn, _opts) do
|
||||
proxies = Application.get_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
if proxies != [] and ClientIp.trusted_ip?(conn.remote_ip, proxies) do
|
||||
rewrite_scheme(conn)
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
# Mirrors Plug.RewriteOn's :x_forwarded_proto semantics.
|
||||
defp rewrite_scheme(conn) do
|
||||
case get_req_header(conn, "x-forwarded-proto") do
|
||||
["https" | _rest] -> %{conn | scheme: :https}
|
||||
["http" | _rest] -> %{conn | scheme: :http}
|
||||
_other -> conn
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue