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.
This commit is contained in:
Graham McIntire 2026-05-11 18:53:21 -05:00
parent 91c9d98a99
commit 14b90ee9f3
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
24 changed files with 636 additions and 124 deletions

45
bugs.md
View file

@ -1,27 +1,28 @@
# Identified Bugs and Observations (Post-Fix Review)
# Bugs Found 2026-05-11
This document reflects the state of the codebase after the fixes for the initial 15 bugs were applied.
The first batch (7 items: pending-beacon access control, per_page contract,
rate-limiter ETS lifecycle, unbounded grid endpoints, NEXRAD PNG quadratic
unfilter, LiveTableFooter parser, ScoresFile parser) was fixed and removed
from this file as each was resolved. The items below are additional bugs
surfaced by a follow-up audit.
All three items below were verified, covered by failing tests, and fixed on 2026-04-25.
## A. `String.to_integer` on user-controlled `band` param crashes LiveViews
## 1. Haversine Implementation Inconsistency — FIXED 2026-04-25
**Files:** `lib/microwaveprop/radio.ex` vs `lib/microwaveprop/commercial.ex`
**Description:**
- `Radio.haversine_km/4` uses the `asin` form: `2 * r * :math.asin(:math.sqrt(a))`
- `Commercial.haversine_km/4` uses the `atan2` form: `2 * r * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))`
**Impact:** While functionally similar, `atan2` is the industry standard for floating-point stability at large distances. Having two different implementations for the same physical calculation can lead to tiny discrepancies (sub-millimeter) that might cause a contact or commercial link to intermittently cross a distance threshold (e.g., 75 km) depending on which module performs the check.
**Fix:** Switched `Radio.haversine_km/4` to the `atan2` form and made `Commercial`'s private wrapper delegate to it. One canonical implementation, no per-module drift. Numerical-stability regression test added in `test/microwaveprop/radio_test.exs`.
**Status:** Fixed.
**Severity:** High
**Category:** Availability / unhandled crash on user input
**Files:**
- `lib/microwaveprop_web/live/path_live.ex` (formerly lines 227, 284)
- `lib/microwaveprop_web/live/map_live.ex` (formerly line 198)
## 2. Grid Snapping Mathematical Drift — FIXED 2026-04-25
**Files:** `lib/microwaveprop/propagation/profiles_file.ex` vs `rust/prop_grid_rs/src/profiles_file.rs`
**Description:**
- Elixir `snap/2`: `Float.round(Float.round(lat / step) * step, 3)`
- Rust `snap_coords`: `(lat * 1000.0).round() / 1000.0`
**Impact:** The Elixir version performs a division and multiplication by the grid step (`0.125`) before rounding. The Rust version rounds the raw coordinate to the third decimal place. For coordinates that land exactly on a $0.0625$ boundary (the halfway point between cells), these two methods may snap to different cells, causing the UI to report "No Data" or show the wrong factor breakdown for a clicked point.
**Fix:** Rust `snap_coords` now mirrors the Elixir step-aware snap (`round(coord / 0.125) * 0.125`, then 3-decimal round). Cross-stack parity test in `rust/prop_grid_rs/src/profiles_file.rs` enumerates known-disagreeing inputs.
`PathLive.handle_event("calculate", …)` and the auto-calculate path both
called `String.to_integer(params["band"])` directly on the `band` form
parameter. `MapLive.handle_event("select_band", %{"value" => band}, …)`
did the same. A non-numeric `band` value — supplied via a hand-crafted
URL (`/path?band=abc`) or a misbehaving JS hook — raised `ArgumentError`
inside the LiveView process and crashed the channel.
## 3. Potential Exception in Wgrib2 Parsing (Theoretical) — FIXED 2026-04-25
**File:** `lib/microwaveprop/weather/grib2/wgrib2.ex`
**Description:** `parse_lon_val_segment/1` uses `String.to_float(lon_str)` for the longitude, but `Float.parse` for latitude and value.
**Impact:** If `wgrib2` were to ever return an integer string for longitude (e.g. `"235"`) instead of a float (`"235.0"`), `String.to_float` would raise an `ArgumentError`. While `wgrib2` typically outputs floats, using `Float.parse` would be more consistent with the other fields in the same line.
**Fix:** All three numeric fields now go through `Float.parse/1` (in a `with` chain so a malformed segment returns `nil` instead of bringing down the chain step). Function exposed as `@doc false` and covered by direct unit tests for both decimal-formatted and integer-formatted longitude strings.
**Fix:** Use the existing `LiveHelpers.parse_int/2` (PathLive) and the
existing `parse_band_param/1` plus a small `normalize_band_event/1` helper
(MapLive). Unknown bands now fall back to a safe default or a no-op
instead of crashing the process.

View file

@ -58,7 +58,11 @@ defmodule Microwaveprop.Application do
# leader to drop. See `Microwaveprop.Pskr.Client`. Disabled
# in dev/test so `mix test` doesn't try to dial out.
Microwaveprop.Pskr.Aggregator,
pskr_client_child_spec()
pskr_client_child_spec(),
# Periodic GC for the API rate limiter ETS table; without this
# the table accumulates one row per distinct caller per window
# for the lifetime of the BEAM.
MicrowavepropWeb.Api.RateLimiter.Sweeper
]
# See https://hexdocs.pm/elixir/Supervisor.html

View file

@ -75,6 +75,33 @@ defmodule Microwaveprop.Beacons do
@spec get_beacon!(Ecto.UUID.t()) :: Beacon.t() | [Beacon.t()] | nil
def get_beacon!(id), do: Beacon |> Repo.get!(id) |> Repo.preload(:user)
@doc """
Returns a beacon only if the viewer is allowed to see it. Approved
beacons are visible to everyone; pending (unapproved) beacons are
visible only to the submitter and admins. Returns `nil` for
not-found, malformed ids, and unauthorized viewers callers render
the same 404 in each case so existence of a pending beacon is not
observable.
"""
@spec get_visible_beacon(Ecto.UUID.t() | String.t(), User.t() | nil) :: Beacon.t() | nil
def get_visible_beacon(id, viewer) do
case fetch_beacon(id) do
nil -> nil
beacon -> if can_view?(beacon, viewer), do: beacon
end
end
defp fetch_beacon(id) do
Beacon |> Repo.get(id) |> Repo.preload(:user)
rescue
Ecto.Query.CastError -> nil
end
defp can_view?(%Beacon{approved: true}, _viewer), do: true
defp can_view?(%Beacon{}, %User{is_admin: true}), do: true
defp can_view?(%Beacon{user_id: uid}, %User{id: uid}) when not is_nil(uid), do: true
defp can_view?(%Beacon{}, _viewer), do: false
@doc """
Creates a beacon. When a user is provided they are recorded as the
creator; anonymous submissions pass `nil` and leave `user_id` unset.

View file

@ -480,9 +480,24 @@ defmodule Microwaveprop.Propagation.ScoresFile do
defp fetch_bound(bounds, atom_key, string_key, default) do
case Map.get(bounds, atom_key) || Map.get(bounds, string_key) do
nil -> default
v when is_number(v) -> v
v when is_binary(v) -> String.to_float(v)
nil ->
default
v when is_number(v) ->
v
v when is_binary(v) ->
case Float.parse(v) do
{f, ""} -> f
# `Float.parse` accepts trailing junk (`"1.5xyz"` → `{1.5, "xyz"}`);
# only treat values that fully consume the input as parsed. On
# any other shape, fall back to the caller-supplied default
# rather than raising and crashing the caller.
_ -> default
end
_ ->
default
end
end

View file

@ -14,6 +14,7 @@ defmodule Microwaveprop.Radio do
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@per_page 20
@max_per_page 200
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
@count_cache_key {__MODULE__, :total_contact_count}
@count_cache_ttl_ms 30_000
@ -152,7 +153,8 @@ defmodule Microwaveprop.Radio do
@spec list_contacts(keyword()) :: contact_page()
def list_contacts(opts \\ []) do
page = max(Keyword.get(opts, :page, 1), 1)
offset = (page - 1) * @per_page
per_page = clamp_per_page(Keyword.get(opts, :per_page, @per_page))
offset = (page - 1) * per_page
search = Keyword.get(opts, :search)
scope = Keyword.get(opts, :scope)
@ -164,12 +166,12 @@ defmodule Microwaveprop.Radio do
|> filter_private(scope)
total_entries = total_entries_for(search, scope, base_query)
total_pages = max(ceil(total_entries / @per_page), 1)
total_pages = max(ceil(total_entries / per_page), 1)
entries =
base_query
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}, {^sort_dir, q.id}])
|> limit(^@per_page)
|> limit(^per_page)
|> offset(^offset)
|> Repo.all()
|> Repo.preload(:user)
@ -183,6 +185,10 @@ defmodule Microwaveprop.Radio do
}
end
defp clamp_per_page(value) when is_integer(value) and value >= 1, do: min(value, @max_per_page)
defp clamp_per_page(_), do: @per_page
@doc """
Groups reciprocal contacts (same station pair swapped, same band, same timestamp).
Returns a list of `{primary, reciprocals}` tuples where reciprocals is

View file

@ -388,59 +388,66 @@ defmodule Microwaveprop.Weather.NexradClient do
unfilter_paeth(row, prev, bpp)
end
defp unfilter_sub(row, bpp) do
row_bytes = :binary.bin_to_list(row)
# Indexed-color PNG only ever has bpp = 1 (one palette index byte per
# pixel), so "left" is simply the previously-decoded output byte. We
# walk the input binary once and accumulate output bytes into an
# iolist, which is O(n) — the original `acc ++ [byte]` plus
# `Enum.at(acc, idx - bpp)` was O(n²) per row and dominated decode
# time for the 12200×5400 n0q frame.
defp unfilter_sub(row, 1), do: unfilter_sub_iter(row, 0, [])
{result, _} =
Enum.reduce(row_bytes, {[], 0}, fn byte, {acc, idx} ->
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
defp unfilter_sub_iter(<<>>, _left, acc), do: acc |> Enum.reverse() |> :binary.list_to_bin()
defp unfilter_sub_iter(<<byte, rest::binary>>, left, acc) do
val = rem(byte + left, 256)
{acc ++ [val], idx + 1}
end)
:binary.list_to_bin(result)
unfilter_sub_iter(rest, val, [val | acc])
end
defp unfilter_up(row, prev) do
row_bytes = :binary.bin_to_list(row)
prev_bytes = :binary.bin_to_list(prev)
defp unfilter_up(row, prev), do: unfilter_up_iter(row, prev, [])
row_bytes
|> Enum.zip(prev_bytes)
|> Enum.map(fn {a, b} -> rem(a + b, 256) end)
|> :binary.list_to_bin()
defp unfilter_up_iter(<<>>, _prev, acc), do: acc |> Enum.reverse() |> :binary.list_to_bin()
defp unfilter_up_iter(<<byte, row_rest::binary>>, <<above, prev_rest::binary>>, acc) do
val = rem(byte + above, 256)
unfilter_up_iter(row_rest, prev_rest, [val | acc])
end
defp unfilter_average(row, prev, bpp) do
row_bytes = :binary.bin_to_list(row)
prev_bytes = :binary.bin_to_list(prev)
# `prev` may be shorter than `row` if a row is shorter than the
# previous one — for indexed-color this never happens but we
# defensively treat missing prior bytes as zero.
defp unfilter_up_iter(<<byte, row_rest::binary>>, <<>>, acc) do
val = rem(byte, 256)
unfilter_up_iter(row_rest, <<>>, [val | acc])
end
{result, _} =
Enum.reduce(Enum.with_index(row_bytes), {[], prev_bytes}, fn {byte, idx}, {acc, prev_list} ->
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
above = Enum.at(prev_list, idx, 0)
defp unfilter_average(row, prev, 1), do: unfilter_average_iter(row, prev, 0, [])
defp unfilter_average_iter(<<>>, _prev, _left, acc), do: acc |> Enum.reverse() |> :binary.list_to_bin()
defp unfilter_average_iter(<<byte, row_rest::binary>>, <<above, prev_rest::binary>>, left, acc) do
val = rem(byte + div(left + above, 2), 256)
{acc ++ [val], prev_list}
end)
:binary.list_to_bin(result)
unfilter_average_iter(row_rest, prev_rest, val, [val | acc])
end
defp unfilter_paeth(row, prev, bpp) do
row_bytes = :binary.bin_to_list(row)
prev_bytes = :binary.bin_to_list(prev)
defp unfilter_average_iter(<<byte, row_rest::binary>>, <<>>, left, acc) do
val = rem(byte + div(left, 2), 256)
unfilter_average_iter(row_rest, <<>>, val, [val | acc])
end
{result, _} =
Enum.reduce(Enum.with_index(row_bytes), {[], prev_bytes}, fn {byte, idx}, {acc, prev_list} ->
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
above = Enum.at(prev_list, idx, 0)
upper_left = if idx >= bpp, do: Enum.at(prev_list, idx - bpp, 0), else: 0
defp unfilter_paeth(row, prev, 1), do: unfilter_paeth_iter(row, prev, 0, 0, [])
defp unfilter_paeth_iter(<<>>, _prev, _left, _upper_left, acc), do: acc |> Enum.reverse() |> :binary.list_to_bin()
defp unfilter_paeth_iter(<<byte, row_rest::binary>>, <<above, prev_rest::binary>>, left, upper_left, acc) do
pr = paeth_predictor(left, above, upper_left)
val = rem(byte + pr, 256)
{acc ++ [val], prev_list}
end)
unfilter_paeth_iter(row_rest, prev_rest, val, above, [val | acc])
end
:binary.list_to_bin(result)
defp unfilter_paeth_iter(<<byte, row_rest::binary>>, <<>>, left, _upper_left, acc) do
pr = paeth_predictor(left, 0, 0)
val = rem(byte + pr, 256)
unfilter_paeth_iter(row_rest, <<>>, val, 0, [val | acc])
end
defp paeth_predictor(a, b, c) do

View file

@ -2,10 +2,10 @@ defmodule MicrowavepropWeb.Api.RateLimiter do
@moduledoc """
Tiny ETS-backed fixed-window rate limiter for `/api/v1`.
No external dependency, no supervisor entry the named ETS table
is created lazily on the first request and survives for the
lifetime of the BEAM. Each (bucket, window) pair holds a counter
incremented atomically via `:ets.update_counter/4`.
The named ETS table is created at application boot (and re-created
idempotently if the boot path is bypassed in tests). Each
`{bucket, window}` pair holds a counter incremented atomically via
`:ets.update_counter/4`.
Default limits (overridable per plug invocation):
* authenticated requests: 600 / minute, keyed by API token id
@ -13,6 +13,14 @@ defmodule MicrowavepropWeb.Api.RateLimiter do
The plug emits the RFC 9651 `RateLimit` headers and a 429
problem+json response when the bucket is exhausted.
## Lifecycle
Without a sweeper the table grows one row per distinct `{bucket,
window}` and never shrinks every distinct IP / token over the
pod's lifetime stays resident. The companion `Sweeper` GenServer
prunes rows whose window has fully expired on a fixed cadence; see
`MicrowavepropWeb.Api.RateLimiter.Sweeper`.
"""
@behaviour Plug
@ -26,15 +34,28 @@ defmodule MicrowavepropWeb.Api.RateLimiter do
@default_anon_limit 60
@default_auth_limit 600
@doc "Returns the ETS table name backing the limiter."
@spec table() :: atom()
def table, do: @table
@doc "Returns the default window size in milliseconds."
@spec default_window_ms() :: pos_integer()
def default_window_ms, do: @default_window_ms
@doc """
Initializes the named ETS table. Idempotent safe to call from
application boot and from tests. Returns `:ok` whether the table
existed already or was created by this call.
application boot and from tests. The race between `whereis` and
`:ets.new` is handled by rescuing `ArgumentError`, which is what
`:ets.new` raises when the table already exists.
"""
@spec init_table() :: :ok
def init_table do
if :ets.whereis(@table) == :undefined do
try do
:ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
rescue
ArgumentError -> :ok
end
end
:ok

View file

@ -0,0 +1,103 @@
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

View file

@ -16,7 +16,9 @@ defmodule MicrowavepropWeb.Api.V1.BeaconController do
end
def show(conn, %{"id" => id}) do
case fetch_beacon(id) do
viewer = conn.assigns[:current_api_user]
case Beacons.get_visible_beacon(id, viewer) do
nil -> ErrorJSON.send_problem(conn, 404, "not_found", "Beacon not found.")
beacon -> json(conn, BeaconJSON.show(%{beacon: beacon}))
end
@ -41,12 +43,4 @@ defmodule MicrowavepropWeb.Api.V1.BeaconController do
ErrorJSON.send_changeset(conn, changeset)
end
end
# `Beacons.get_beacon!/1` raises on bad ids; we want a clean 404.
defp fetch_beacon(id) do
Beacons.get_beacon!(id)
rescue
Ecto.NoResultsError -> nil
Ecto.Query.CastError -> nil
end
end

View file

@ -24,6 +24,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactController do
%{entries: entries, total_entries: total, total_pages: total_pages} =
Radio.list_contacts(
page: page,
per_page: per_page,
search: search,
scope: Scope.for_user(viewer)
)

View file

@ -5,16 +5,25 @@ defmodule MicrowavepropWeb.ScoresController do
the /map overlay so the client can fetch directly (cacheable, doesn't
block the LiveView channel) and decode via DataView + typed-array
views without a JSON parse.
Rate-limited per IP and bounds-capped to the propagation grid extent
so a malicious caller cannot trigger global / oversized binary
responses.
"""
use MicrowavepropWeb, :controller
alias Microwaveprop.Propagation
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.GridBounds
plug RateLimiter, anon_limit: 120, auth_limit: 600
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
def cells(conn, %{"band" => band_param} = params) do
with {band_mhz, ""} <- Integer.parse(band_param),
{:ok, valid_time} <- parse_optional_time(params["time"]),
{:ok, bounds} <- parse_bounds(params) do
{:ok, bounds} <- parse_bounds(params),
{:ok, bounds} <- GridBounds.clamp(bounds) do
scores =
if valid_time do
Propagation.scores_at(band_mhz, valid_time, bounds)
@ -27,6 +36,7 @@ defmodule MicrowavepropWeb.ScoresController do
|> put_resp_header("cache-control", "public, max-age=60")
|> send_resp(200, encode_binary(scores))
else
{:error, :viewport_too_large} -> payload_too_large(conn)
_ -> bad_request(conn)
end
end
@ -39,6 +49,12 @@ defmodule MicrowavepropWeb.ScoresController do
|> json(%{error: "invalid params"})
end
defp payload_too_large(conn) do
conn
|> put_status(413)
|> json(%{error: "viewport_too_large", detail: "Requested bounds exceed the maximum supported viewport area."})
end
defp parse_optional_time(nil), do: {:ok, nil}
defp parse_optional_time(value) when is_binary(value) do

View file

@ -2,6 +2,10 @@ defmodule MicrowavepropWeb.WeatherTileController do
use MicrowavepropWeb, :controller
alias Microwaveprop.Weather
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.GridBounds
plug RateLimiter, anon_limit: 120, auth_limit: 600
# Layer fields the binary cell-pack can carry. Used both as the
# whitelist for ?layers=... and as the default if the param is absent.
@ -17,6 +21,7 @@ defmodule MicrowavepropWeb.WeatherTileController do
def cells(conn, %{"time" => time_param} = params) do
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
{:ok, bounds} <- parse_bounds(params),
{:ok, bounds} <- GridBounds.clamp(bounds),
{:ok, layers} <- parse_layers(params["layers"]) do
rows = read_rows(valid_time, bounds, params["source"])
@ -25,6 +30,7 @@ defmodule MicrowavepropWeb.WeatherTileController do
|> put_resp_header("cache-control", "public, max-age=60")
|> send_resp(200, encode_binary(rows, layers))
else
{:error, :viewport_too_large} -> payload_too_large(conn)
_ -> bad_request(conn)
end
end
@ -40,6 +46,12 @@ defmodule MicrowavepropWeb.WeatherTileController do
|> json(%{error: "invalid params"})
end
defp payload_too_large(conn) do
conn
|> put_status(413)
|> json(%{error: "viewport_too_large", detail: "Requested bounds exceed the maximum supported viewport area."})
end
defp parse_bounds(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
with {:ok, south} <- parse_float(s),
{:ok, north} <- parse_float(n),

View file

@ -0,0 +1,73 @@
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

View file

@ -159,9 +159,17 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
_ = if connected?(socket), do: Beacons.subscribe_beacons()
viewer = socket.assigns[:current_scope] && socket.assigns.current_scope.user
beacon = Beacons.get_beacon!(id)
case Beacons.get_visible_beacon(id, viewer) do
nil ->
# Pending beacons are invisible to everyone except the
# submitter and admins. Render the same 404 we'd give for an
# unknown id so existence isn't observable.
raise Ecto.NoResultsError, queryable: Beacon
beacon ->
_ = if connected?(socket), do: Beacons.subscribe_beacons()
{:ok,
socket
@ -173,6 +181,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
# paying the bbox grid cost on every page load.
|> assign(:estimate, nil)}
end
end
# Estimated current coverage only makes sense from 5.76 GHz upward,
# where atmospheric attenuation meaningfully shapes the reach. Below

View file

@ -195,21 +195,15 @@ defmodule MicrowavepropWeb.MapLive do
@impl true
def handle_event("select_band", %{"value" => band}, socket) do
band = if is_binary(band), do: String.to_integer(band), else: band
valid_times = Propagation.available_valid_times(band)
selected_time = closest_to_now(valid_times)
socket =
socket
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
|> MicrowavepropWeb.LiveStashGuard.stash()
# band_mhz tells the client to refetch /scores/cells for the new band.
|> push_event("update_band_info", %{band_info: band_info(band), band_mhz: band})
|> push_timeline()
|> patch_map_url()
case normalize_band_event(band) do
nil ->
# Reject unknown / unparseable bands rather than crashing the
# LiveView with String.to_integer/BandConfig.get failures.
{:noreply, socket}
band ->
do_select_band(socket, band)
end
end
def handle_event("select_time", %{"time" => time_str}, socket) do
@ -511,6 +505,25 @@ defmodule MicrowavepropWeb.MapLive do
end
end
defp normalize_band_event(band) when is_integer(band), do: if(BandConfig.get(band), do: band)
defp normalize_band_event(band) when is_binary(band), do: parse_band_param(band)
defp normalize_band_event(_), do: nil
defp do_select_band(socket, band) do
valid_times = Propagation.available_valid_times(band)
selected_time = closest_to_now(valid_times)
socket
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
|> MicrowavepropWeb.LiveStashGuard.stash()
# band_mhz tells the client to refetch /scores/cells for the new band.
|> push_event("update_band_info", %{band_info: band_info(band), band_mhz: band})
|> push_timeline()
|> patch_map_url()
|> then(&{:noreply, &1})
end
@doc false
def parse_time_param(nil), do: nil

View file

@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.PathLive do
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.Components.SkewTChart
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2]
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
@ -224,7 +224,7 @@ defmodule MicrowavepropWeb.PathLive do
dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0)
}
send(self(), {:compute_path, p["source"], p["destination"], String.to_integer(p["band"]), station_params})
send(self(), {:compute_path, p["source"], p["destination"], parse_int(p["band"], 10_000), station_params})
{:noreply, assign(socket, computing: true)}
else
{:noreply, socket}
@ -281,7 +281,7 @@ defmodule MicrowavepropWeb.PathLive do
send(
self(),
{:compute_path, params["source"], params["destination"], String.to_integer(params["band"]), station_params}
{:compute_path, params["source"], params["destination"], parse_int(params["band"], 10_000), station_params}
)
url_params =

View file

@ -91,7 +91,14 @@ defmodule MicrowavepropWeb.LiveTableFooter do
end
end
defp parse_page(page) when is_integer(page), do: page
defp parse_page(page) when is_binary(page), do: String.to_integer(page)
defp parse_page(page) when is_integer(page) and page >= 1, do: page
defp parse_page(page) when is_binary(page) do
case Integer.parse(page) do
{n, ""} when n >= 1 -> n
_ -> 1
end
end
defp parse_page(_), do: 1
end

View file

@ -69,6 +69,39 @@ defmodule Microwaveprop.RadioTest do
assert result.page == 2
assert result.total_pages == 2
end
test "honors per_page option" do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
result = Radio.list_contacts(per_page: 50)
assert length(result.entries) == 25
assert result.total_pages == 1
end
test "clamps per_page to the maximum" do
for i <- 1..3 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
# 5000 is well above the 200 cap; should still return all 3 rows.
result = Radio.list_contacts(per_page: 5000)
assert length(result.entries) == 3
end
test "falls back to default when per_page is invalid" do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
result = Radio.list_contacts(per_page: 0)
assert length(result.entries) == 20
end
end
describe "list_contacts/1 sorting" do

View file

@ -79,4 +79,54 @@ defmodule MicrowavepropWeb.Api.RateLimiterTest do
assert RateLimiter.reset() == :ok
end
end
describe "init_table/0" do
test "is a no-op when the table already exists" do
RateLimiter.init_table()
assert RateLimiter.init_table() == :ok
end
test "tolerates many concurrent init_table calls" do
# `call/2` invokes `init_table/0` on every request; under heavy
# load many requests can fly through the whereis check before
# any of them call `:ets.new`. The rescue path makes the loser
# of that race return `:ok` instead of crashing. The Sweeper
# owns the canonical table from the application supervisor, so
# concurrent calls must never leave the system without one.
tasks =
for _ <- 1..16 do
Task.async(fn -> RateLimiter.init_table() end)
end
results = Task.await_many(tasks)
assert Enum.all?(results, &(&1 == :ok))
assert :ets.whereis(RateLimiter.table()) != :undefined
end
end
describe "Sweeper" do
alias MicrowavepropWeb.Api.RateLimiter.Sweeper
test "sweep_now removes expired window rows but keeps current ones" do
RateLimiter.reset()
table = RateLimiter.table()
now = System.system_time(:millisecond)
window_ms = RateLimiter.default_window_ms()
current = div(now, window_ms)
:ets.insert(table, {{{:ip, "stale-1"}, current - 5}, 1})
:ets.insert(table, {{{:ip, "stale-2"}, current - 1}, 4})
:ets.insert(table, {{{:ip, "current"}, current}, 2})
assert :ets.info(table, :size) == 3
{:ok, pid} = Sweeper.start_link(name: :rate_limiter_sweeper_test, interval_ms: 60_000_000)
deleted = Sweeper.sweep_now(pid)
assert deleted == 2
assert :ets.info(table, :size) == 1
assert :ets.lookup(table, {{:ip, "current"}, current}) != []
end
end
end

View file

@ -37,12 +37,45 @@ defmodule MicrowavepropWeb.Api.V1.BeaconControllerTest do
end
describe "GET /api/v1/beacons/:id" do
test "returns the beacon when found", %{conn: conn, user: user} do
test "returns approved beacons to anonymous viewers", %{conn: conn, user: user} do
{:ok, b} = Beacons.create_beacon(user, @valid)
{:ok, b} = Beacons.approve_beacon(b)
body = conn |> get(~p"/api/v1/beacons/#{b.id}") |> json_response(200)
assert body["data"]["id"] == b.id
end
test "404 for anonymous viewers on unapproved beacons", %{conn: conn, user: user} do
{:ok, b} = Beacons.create_beacon(user, @valid)
conn = get(conn, ~p"/api/v1/beacons/#{b.id}")
assert json_response(conn, 404)
end
test "submitter can view their own unapproved beacon", %{conn: conn, user: user, plaintext: pt} do
{:ok, b} = Beacons.create_beacon(user, @valid)
body =
conn
|> put_req_header("authorization", "Bearer " <> pt)
|> get(~p"/api/v1/beacons/#{b.id}")
|> json_response(200)
assert body["data"]["id"] == b.id
assert body["data"]["approved"] == false
end
test "non-owner non-admin gets 404 on another user's unapproved beacon", %{conn: conn, user: user} do
{:ok, b} = Beacons.create_beacon(user, @valid)
other = AccountsFixtures.user_fixture()
{:ok, {other_pt, _}} = Accounts.create_api_token(other, %{name: "t"})
conn =
conn
|> put_req_header("authorization", "Bearer " <> other_pt)
|> get(~p"/api/v1/beacons/#{b.id}")
assert json_response(conn, 404)
end
test "404 on unknown id", %{conn: conn} do
conn = get(conn, ~p"/api/v1/beacons/00000000-0000-0000-0000-000000000000")
assert json_response(conn, 404)

View file

@ -100,5 +100,11 @@ defmodule MicrowavepropWeb.ScoresControllerTest do
conn = get(conn, ~p"/scores/cells?band=10000&time=&south=0&north=1&west=-1&east=0")
assert json_response(conn, 400) == %{"error" => "invalid params"}
end
test "returns 413 for global / oversized viewport bounds", %{conn: conn} do
conn = get(conn, ~p"/scores/cells?band=10000&south=-90&north=90&west=-180&east=180")
body = json_response(conn, 413)
assert body["error"] == "viewport_too_large"
end
end
end

View file

@ -165,5 +165,18 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
File.rm_rf!(hrdps_dir)
end
test "returns 413 for global / oversized viewport bounds", %{conn: conn} do
time = DateTime.to_iso8601(~U[2026-04-28 12:00:00Z])
conn =
get(
conn,
~p"/weather/cells?time=#{time}&south=-90&north=90&west=-180&east=180"
)
body = json_response(conn, 413)
assert body["error"] == "viewport_too_large"
end
end
end

View file

@ -0,0 +1,38 @@
defmodule MicrowavepropWeb.GridBoundsTest do
use ExUnit.Case, async: true
alias MicrowavepropWeb.GridBounds
describe "clamp/1" do
test "passes nil through" do
assert GridBounds.clamp(nil) == {:ok, nil}
end
test "passes a small viewport through unchanged" do
bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -90.0}
assert {:ok, ^bounds} = GridBounds.clamp(bounds)
end
test "clamps south/north/west/east to the supported extent" do
bounds = %{"south" => -50.0, "north" => 90.0, "west" => -200.0, "east" => 50.0}
assert {:error, :viewport_too_large} = GridBounds.clamp(bounds)
end
test "rejects global bounds with 413-equivalent error" do
global = %{"south" => -90.0, "north" => 90.0, "west" => -180.0, "east" => 180.0}
assert GridBounds.clamp(global) == {:error, :viewport_too_large}
end
test "rejects inverted bounds" do
inverted = %{"south" => 40.0, "north" => 30.0, "west" => -90.0, "east" => -100.0}
assert GridBounds.clamp(inverted) == {:error, :invalid_bounds}
end
test "rejects malformed bounds" do
assert GridBounds.clamp(%{}) == {:error, :invalid_bounds}
assert GridBounds.clamp(%{"south" => "x", "north" => 1, "west" => 1, "east" => 1}) ==
{:error, :invalid_bounds}
end
end
end

View file

@ -89,12 +89,39 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
assert html =~ "10,368.1"
end
test "shows pending badge on unapproved beacons", %{conn: conn} do
beacon = beacon_fixture(user_fixture())
test "shows pending badge to the submitter on their own unapproved beacon", %{conn: conn} do
user = user_fixture()
beacon = beacon_fixture(user)
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
assert html =~ "Pending approval"
end
test "anonymous viewer gets 404 on unapproved beacon", %{conn: conn} do
beacon = beacon_fixture(user_fixture(), callsign: "W5HID")
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/beacons/#{beacon}")
end
end
test "logged-in non-owner non-admin gets 404 on unapproved beacon", %{conn: conn} do
beacon = beacon_fixture(user_fixture(), callsign: "W5HID")
conn = log_in_user(conn, user_fixture())
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/beacons/#{beacon}")
end
end
test "admin sees unapproved beacon", %{conn: conn} do
beacon = beacon_fixture(user_fixture(), callsign: "W5ADM")
conn = log_in_user(conn, admin_user_fixture())
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
assert html =~ "Pending approval"
assert html =~ "W5ADM"
end
test "plot-path-to-beacon link pre-populates tx_power + gain to match beacon EIRP",
%{conn: conn} do
# power_mw: 10_000 → EIRP 40 dBm → default-gain 30 dBi → tx 10 dBm
@ -358,12 +385,15 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
refute html =~ "src_gain_dbi="
end
test "non-admin unauthenticated user clicking 'approve' is rejected with a flash",
test "submitter clicking 'approve' on their own pending beacon is rejected with a flash",
%{conn: conn} do
# The approve handler is rendered for admins only but the server
# still defends the admin check. Simulate a crafted client pushing
# the event manually with no scope; the flash branch should fire.
pending = beacon_fixture(user_fixture(), callsign: "W5DEF")
# the event manually as the (non-admin) submitter; the flash branch
# should fire and the beacon must remain unapproved.
user = user_fixture()
pending = beacon_fixture(user, callsign: "W5DEF")
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/beacons/#{pending}")