aprs.me/lib/aprsme_web/plugs/rate_limiter.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

131 lines
3.8 KiB
Elixir

defmodule AprsmeWeb.Plugs.RateLimiter do
@moduledoc """
Rate limiting plug to prevent DoS attacks
"""
import Phoenix.Controller
import Plug.Conn
@type key_type :: :ip | :user_agent | (Plug.Conn.t() -> String.t()) | String.t()
@type init_opts :: [
scale: integer(),
limit: integer(),
key: key_type(),
prefix: String.t(),
error_message: String.t()
]
@spec init(Keyword.t()) :: init_opts()
def init(opts) do
# Default options
Keyword.merge(
[
# 1 minute
scale: 60_000,
# 100 requests per minute
limit: 100,
# Rate limit by IP address
key: :ip,
# Prefix for ETS key namespace
prefix: "rate_limit",
error_message: "Too many requests"
],
opts
)
end
@spec call(Plug.Conn.t(), init_opts()) :: Plug.Conn.t()
def call(conn, opts) do
key = get_key(conn, opts[:key])
scale = opts[:scale]
limit = opts[:limit]
prefix = opts[:prefix]
error_message = opts[:error_message]
case Aprsme.RateLimiter.hit("#{prefix}:#{key}", scale, limit) do
{:allow, _count} ->
conn
{:deny, _retry_after} ->
conn
|> put_status(:too_many_requests)
|> json(%{error: error_message})
|> halt()
end
end
# ---------------------------------------------------------------------------
# Public helpers for use outside the plug pipeline (LiveViews, channels, etc.)
# ---------------------------------------------------------------------------
@doc """
Returns the client IP address from a LiveView socket.
Uses `get_connect_info/2` to extract `:peer_data` — ensure the endpoint
socket declaration includes `:peer_data` in its `connect_info` list.
Falls back to `"unknown"` when peer_data is unavailable.
"""
@spec extract_socket_ip(Phoenix.LiveView.Socket.t()) :: String.t()
def extract_socket_ip(socket) do
case Phoenix.LiveView.get_connect_info(socket, :peer_data) do
%{address: address} when not is_nil(address) ->
address |> :inet.ntoa() |> to_string()
_ ->
# Fall back to socket assign set during mount (dead render path)
Map.get(socket.assigns, :client_ip, "unknown")
end
end
@doc """
Minimal rate-limit check for use in LiveView event handlers and channels.
Returns `{:allow, count}` or `{:deny, retry_after_ms}`.
"""
@spec check_rate_limit(String.t(), integer(), integer()) ::
{:allow, non_neg_integer()} | {:deny, non_neg_integer()}
def check_rate_limit(key, scale, limit) do
Aprsme.RateLimiter.hit(key, scale, limit)
end
# ---------------------------------------------------------------------------
# Private key-extraction helpers
# ---------------------------------------------------------------------------
@spec get_key(Plug.Conn.t(), key_type()) :: String.t()
defp get_key(conn, :ip) do
# Check headers in order of preference
case {get_req_header(conn, "cf-connecting-ip"), get_req_header(conn, "x-forwarded-for"),
get_req_header(conn, "x-real-ip")} do
# Cloudflare header takes precedence
{[cf | _], _, _} ->
cf
# Then standard X-Forwarded-For header
{[], [forwarded | _], _} ->
forwarded |> String.split(",") |> List.first() |> String.trim()
# Then X-Real-IP header
{[], [], [real | _]} ->
real
# Fall back to remote_ip
{[], [], []} ->
conn.remote_ip |> :inet.ntoa() |> to_string()
end
end
defp get_key(conn, :user_agent) do
case get_req_header(conn, "user-agent") do
[ua | _] -> ua
[] -> "unknown"
end
end
defp get_key(conn, custom_key) when is_function(custom_key) do
custom_key.(conn)
end
defp get_key(_conn, key) when is_binary(key) do
key
end
end