mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-08-24 23:06:42 +00:00
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.
169 lines
5.9 KiB
Elixir
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
|