Files
Guarzo 5349b5088c fix: authorize client-supplied IDs in LiveView handlers (IDOR audit)
LiveView event handlers were trusting client-supplied record IDs without
verifying they belong to the user's current map/user scope. A logged-in
user could push another map's record UUID over the LV socket and act on
it. Affected handlers:

- cancel-subscription / edit-subscription / update_subscription
  (cancel or upgrade any map's paid subscription by ID)
- characters_live.ex "delete"
  (soft-delete any user's character and null their user_id)
- cancel_ping
  (cross-map rally-ping cancellation broadcast)
- deleteSystemComment
  (cross-map comment deletion)
- update_passage_mass
  (cross-map wormhole passage mass corruption)
- updateCharacterTracking
  (untrack another user's character on a shared map)

Also fixes a related numeric-trust bug in subscribe/update_subscription
where `period`, `characters_limit`, and `hubs_limit` were parsed without
bounds, allowing `period: "-1"` to produce a back-dated subscription
with a negative price.

Adds WandererAppWeb.HandlerAuth with focused helpers each handler now
routes through, plus 26 regression tests covering both the IDOR auth
checks and the bounded-int parsers.
2026-06-10 14:31:56 +00:00

169 lines
5.9 KiB
Elixir

defmodule WandererAppWeb.HandlerAuth do
@moduledoc """
Authorization helpers used by LiveView event handlers to verify that a
client-supplied record id belongs to the map (or user) currently in scope.
These helpers exist because LiveView `handle_event` callbacks receive
unvalidated client params. A malicious client can submit any UUID it likes;
the server must check that the record actually belongs to the authenticated
context before acting on it.
Each helper returns `{:ok, record}` when the lookup succeeds AND the record
is scoped to the given context, or `{:error, :not_found}` otherwise.
We deliberately return `:not_found` (rather than `:unauthorized`) for both
the "record does not exist" and "record exists but on a different map"
cases — this avoids leaking the existence of records in other contexts.
"""
@doc """
Returns `{:ok, subscription}` if the subscription exists and belongs to `map_id`,
`{:error, :not_found}` otherwise.
"""
def authorize_subscription(subscription_id, map_id) when is_binary(map_id) do
with {:ok, subscription} <- WandererApp.Api.MapSubscription.by_id(subscription_id),
true <- subscription.map_id == map_id do
{:ok, subscription}
else
_ -> {:error, :not_found}
end
end
def authorize_subscription(_subscription_id, _map_id), do: {:error, :not_found}
@doc """
Returns `{:ok, character}` if the character exists and is owned by `user_id`,
`{:error, :not_found}` otherwise.
"""
def authorize_character(character_id, user_id) when is_binary(user_id) do
with {:ok, character} <- WandererApp.Api.Character.by_id(character_id),
true <- character.user_id == user_id do
{:ok, character}
else
_ -> {:error, :not_found}
end
end
def authorize_character(_character_id, _user_id), do: {:error, :not_found}
@doc """
Returns `{:ok, ping}` if the ping exists and belongs to `map_id`,
`{:error, :not_found}` otherwise.
Uses the `:all_pings` read action (which has no actor filter) so the
helper can do the map-scoping check itself. The primary `:read` action's
`FilterPingsByActorMap` preparation is for token-based API auth and is
not applicable in the LiveView context.
"""
def authorize_ping(ping_id, map_id) when is_binary(map_id) do
require Ash.Query
result =
WandererApp.Api.MapPing
|> Ash.Query.for_read(:all_pings)
|> Ash.Query.filter(id == ^ping_id)
|> Ash.read_one()
case result do
{:ok, %{} = ping} ->
if ping.map_id == map_id, do: {:ok, ping}, else: {:error, :not_found}
_ ->
{:error, :not_found}
end
end
def authorize_ping(_ping_id, _map_id), do: {:error, :not_found}
@doc """
Returns `{:ok, comment}` if the comment's system belongs to `map_id`,
`{:error, :not_found}` otherwise.
Comments live on a system (`comment.system_id`), and we want the comment's
system to belong to the current map.
"""
def authorize_system_comment(comment_id, map_id) when is_binary(map_id) do
with {:ok, comment} <- WandererApp.MapSystemCommentRepo.get_by_id(comment_id),
{:ok, system} <- WandererApp.Api.MapSystem.by_id(comment.system_id),
true <- system.map_id == map_id do
{:ok, comment}
else
_ -> {:error, :not_found}
end
end
def authorize_system_comment(_comment_id, _map_id), do: {:error, :not_found}
@doc """
Returns `{:ok, passage}` if the passage exists and belongs to `map_id`,
`{:error, :not_found}` otherwise.
"""
def authorize_passage(passage_id, map_id) when is_binary(map_id) do
with {:ok, passage} when not is_nil(passage) <-
WandererApp.Api.MapChainPassages.by_id(passage_id),
true <- passage.map_id == map_id do
{:ok, passage}
else
_ -> {:error, :not_found}
end
end
def authorize_passage(_passage_id, _map_id), do: {:error, :not_found}
@doc """
Returns `true` if the given `eve_id` matches any character in the user's
loaded character list.
"""
def user_owns_character_eve_id?(user_characters, eve_id) when is_list(user_characters) do
eve_id_str = "#{eve_id}"
Enum.any?(user_characters, fn c -> "#{c.eve_id}" == eve_id_str end)
end
def user_owns_character_eve_id?(_user_characters, _eve_id), do: false
@doc """
Parses a client-supplied subscription period (in months).
Returns `{:ok, n}` only for values offered by the form select
(1, 3, 6, or 12 months). Anything else — non-numeric strings, negative
numbers, very large numbers — returns `{:error, message}`.
This blocks the negative-period exploit where `period: "-1"` produces
a back-dated subscription and flips the price calculation to negative.
"""
@valid_subscription_periods [1, 3, 6, 12]
def parse_subscription_period(period) when is_binary(period) do
case Integer.parse(period) do
{n, ""} when n in @valid_subscription_periods -> {:ok, n}
_ -> {:error, "Invalid subscription period."}
end
end
def parse_subscription_period(_), do: {:error, "Invalid subscription period."}
@doc """
Parses a client-supplied `characters_limit` value. The form range input
allows 50..5000 step 50; anything outside that range is rejected.
"""
def parse_characters_limit(value), do: parse_bounded_int(value, 50, 5_000, "characters limit")
@doc """
Parses a client-supplied `hubs_limit` value. The form range input
allows 20..50 step 10; anything outside that range is rejected.
"""
def parse_hubs_limit(value), do: parse_bounded_int(value, 20, 50, "hubs limit")
defp parse_bounded_int(value, min, max, label) when is_binary(value) do
case Integer.parse(value) do
{n, ""} when n >= min and n <= max -> {:ok, n}
_ -> {:error, "Invalid #{label}."}
end
end
defp parse_bounded_int(value, min, max, _label)
when is_integer(value) and value >= min and value <= max,
do: {:ok, value}
defp parse_bounded_int(_, _, _, label), do: {:error, "Invalid #{label}."}
end