tarakan/lib/tarakan_web/plugs/api_rate_limit.ex
Maxfield Luke af6077b9c3
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
2026-07-29 04:43:40 -04:00

111 lines
3.6 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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