prop/lib/microwaveprop_web/grid_bounds.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

73 lines
2.7 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 MicrowavepropWeb.GridBounds do
@moduledoc """
Bounds clamp + sanity checks for grid-cell HTTP endpoints
(`/scores/cells`, `/weather/cells`).
The propagation grid covers HRRR (CONUS) and HRDPS (Canada to
60°N): roughly lat 25-60, lon -141 to -52 — about 35° × 90° =
3150 sq degrees. Any caller-provided viewport is clamped to a
generous superset of that bbox, and viewports whose area exceeds
the supported region are rejected as `:viewport_too_large` so a
malicious caller cannot trigger global / multi-million-cell
binary responses.
`nil` bounds are passed through (caller intends the full grid).
"""
# Generous superset of HRRR + HRDPS — a few extra degrees on each
# edge so legitimate clients zoomed slightly past the data extent
# still get a 200 with the actual covered cells.
@lat_min 20.0
@lat_max 65.0
@lon_min -150.0
@lon_max -50.0
# Comfortably above the HRDPS+HRRR union (~3150 sq deg), but tight
# enough that the fully-clamped extent (~4500 sq deg) still trips
# the cap — a "give me everything" request is exactly the DoS
# shape we want to reject.
@max_viewport_area_sq_deg 4000.0
@type bounds :: %{optional(String.t()) => float()} | nil
@doc """
Clamp `bounds` to the supported grid extent. Returns:
* `{:ok, nil}` when bounds are nil (caller wants the full grid)
* `{:ok, clamped_bounds}` when the clamped viewport area is OK
* `{:error, :viewport_too_large}` when the clamped area still
exceeds `@max_viewport_area_sq_deg` (catches degenerate
requests like global bounds that survive clamping)
* `{:error, :invalid_bounds}` when north < south or east < west
"""
@spec clamp(bounds()) ::
{:ok, bounds()} | {:error, :viewport_too_large | :invalid_bounds}
def clamp(nil), do: {:ok, nil}
def clamp(%{"south" => s, "north" => n, "west" => w, "east" => e})
when is_number(s) and is_number(n) and is_number(w) and is_number(e) do
south = clamp_value(s, @lat_min, @lat_max)
north = clamp_value(n, @lat_min, @lat_max)
west = clamp_value(w, @lon_min, @lon_max)
east = clamp_value(e, @lon_min, @lon_max)
cond do
north < south or east < west ->
{:error, :invalid_bounds}
(north - south) * (east - west) > @max_viewport_area_sq_deg ->
{:error, :viewport_too_large}
true ->
{:ok, %{"south" => south, "north" => north, "west" => west, "east" => east}}
end
end
def clamp(_), do: {:error, :invalid_bounds}
@doc "Maximum viewport area in square degrees."
@spec max_viewport_area_sq_deg() :: float()
def max_viewport_area_sq_deg, do: @max_viewport_area_sq_deg
defp clamp_value(v, lo, hi), do: v |> max(lo) |> min(hi)
end