prop/lib/microwaveprop_web/api/rate_limiter/sweeper.ex
Graham McIntire 14b90ee9f3
fix(security,perf): address 9 audit findings (access control, DoS, crashes)
- Beacon detail endpoints (LiveView + REST API) now hide unapproved
  beacons from anonymous and unauthorized viewers; only the submitter
  and admins can see pending records before approval. Adds
  Beacons.get_visible_beacon/2 with scope-aware checks.
- API contact pagination now honors per_page end-to-end.
  Radio.list_contacts/1 accepts :per_page and clamps to 200.
- API rate limiter: ETS table is now owned by a long-lived Sweeper
  GenServer (won't die with a request task); Sweeper periodically
  prunes expired-window rows to bound memory; init_table/0 race is
  rescued.
- /scores/cells and /weather/cells: add per-IP rate limiting and a
  shared GridBounds clamp/413 guard so global / oversized viewports
  no longer drive unbounded binary responses.
- NEXRAD PNG unfilter (sub/up/average/paeth): replace acc++[byte]
  + Enum.at(acc, idx-bpp) with O(n) binary recursion. Decode time
  for the 12200x5400 n0q frame goes from quadratic to linear.
- LiveTableFooter.parse_page and ScoresFile.fetch_bound: switch
  String.to_integer/String.to_float to Integer.parse/Float.parse,
  fall back to defaults instead of raising.
- PathLive and MapLive band-event handlers: replace
  String.to_integer(params["band"]) with parse_int / parse_band_param
  so a non-numeric band parameter no longer crashes the LiveView.
2026-05-11 18:53:21 -05:00

103 lines
3 KiB
Elixir

defmodule MicrowavepropWeb.Api.RateLimiter.Sweeper do
@moduledoc """
Periodic GC for the API rate limiter ETS table.
The limiter writes one row per `{bucket, window}` and never
overwrites — without a sweeper the table accumulates one row per
distinct caller per window for the lifetime of the BEAM. This
process scans the table on a fixed cadence and deletes any row
whose window has fully expired (window index strictly less than
the current window index).
Defaults to one sweep per 5 minutes. Configure via:
config :microwaveprop, MicrowavepropWeb.Api.RateLimiter.Sweeper,
interval_ms: 300_000
Test helper `sweep_now/1` runs a sweep synchronously.
"""
use GenServer
alias MicrowavepropWeb.Api.RateLimiter
@default_interval_ms 5 * 60 * 1_000
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc "Run a sweep now. Returns the number of rows deleted."
@spec sweep_now(GenServer.server()) :: non_neg_integer()
def sweep_now(server \\ __MODULE__) do
GenServer.call(server, :sweep_now)
end
@impl true
def init(opts) do
interval_ms =
opts
|> Keyword.get(:interval_ms)
|> Kernel.||(
:microwaveprop
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:interval_ms, @default_interval_ms)
)
# Own the ETS table from this long-lived process. ETS named tables
# die with their owner — having a transient request task or
# short-lived test process create the table leaves the system
# without a backing store the moment that process exits. The
# application boot path also calls `RateLimiter.init_table/0` for
# the cold-start safety net; both paths are idempotent.
:ok = RateLimiter.init_table()
schedule(interval_ms)
{:ok, %{interval_ms: interval_ms}}
end
@impl true
def handle_info(:sweep, state) do
_ = sweep()
schedule(state.interval_ms)
{:noreply, state}
end
@impl true
def handle_call(:sweep_now, _from, state) do
{:reply, sweep(), state}
end
defp schedule(interval_ms) do
Process.send_after(self(), :sweep, interval_ms)
end
defp sweep do
table = RateLimiter.table()
# Defensive: if the table is missing (test that hasn't booted the
# app, etc.) there is nothing to prune.
if :ets.whereis(table) == :undefined do
0
else
window_ms = RateLimiter.default_window_ms()
current_window = div(System.system_time(:millisecond), window_ms)
# Match rows whose window index is strictly less than the
# current window — those windows can no longer be hit (even a
# request arriving exactly at the boundary uses the new
# window). `:ets.select_delete/2` returns the count.
match_spec = [
{
{{:_, :"$1"}, :_},
[{:<, :"$1", current_window}],
[true]
}
]
:ets.select_delete(table, match_spec)
end
end
end