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

309 lines
11 KiB
Elixir

defmodule WandererAppWeb.HandlerAuthTest do
@moduledoc """
Regression tests for the IDOR fixes in LiveView event handlers.
These tests cover `WandererAppWeb.HandlerAuth`, the small authorization
helper that the patched handlers now route client-supplied record IDs
through. Each helper must return `{:error, :not_found}` when a record
belongs to a different map (or user) than the one in the current LV scope.
See the PR description for the full list of handlers these protect.
"""
use WandererApp.DataCase, async: false
alias WandererAppWeb.HandlerAuth
describe "authorize_subscription/2" do
setup do
user = create_user()
character_a = create_character(%{user_id: user.id})
character_b = create_character(%{user_id: user.id})
map_a = create_map(%{owner_id: character_a.id})
map_b = create_map(%{owner_id: character_b.id})
{:ok, subscription_a} =
Ash.create(WandererApp.Api.MapSubscription, %{
map_id: map_a.id,
plan: :omega,
characters_limit: 100,
hubs_limit: 10,
auto_renew?: true,
active_till: DateTime.utc_now() |> DateTime.add(30, :day)
})
{:ok, map_a: map_a, map_b: map_b, subscription_a: subscription_a}
end
test "returns {:ok, sub} when subscription belongs to the given map", %{
map_a: map_a,
subscription_a: subscription_a
} do
assert {:ok, returned} = HandlerAuth.authorize_subscription(subscription_a.id, map_a.id)
assert returned.id == subscription_a.id
end
test "returns {:error, :not_found} when subscription is on a different map", %{
map_b: map_b,
subscription_a: subscription_a
} do
# The classic IDOR repro: pass map_a's subscription id while scoped to map_b.
assert {:error, :not_found} =
HandlerAuth.authorize_subscription(subscription_a.id, map_b.id)
end
test "returns {:error, :not_found} for a nonexistent subscription id", %{map_a: map_a} do
assert {:error, :not_found} =
HandlerAuth.authorize_subscription(Ecto.UUID.generate(), map_a.id)
end
end
describe "authorize_character/2" do
setup do
user_a = create_user()
user_b = create_user()
character_a = create_character(%{user_id: user_a.id})
{:ok, user_a: user_a, user_b: user_b, character_a: character_a}
end
test "returns {:ok, char} when character is owned by the given user", %{
user_a: user_a,
character_a: character_a
} do
assert {:ok, returned} = HandlerAuth.authorize_character(character_a.id, user_a.id)
assert returned.id == character_a.id
end
test "returns {:error, :not_found} when character belongs to a different user", %{
user_b: user_b,
character_a: character_a
} do
# Repro for characters_live.ex "delete": user B pushes user A's character_id.
assert {:error, :not_found} = HandlerAuth.authorize_character(character_a.id, user_b.id)
end
test "returns {:error, :not_found} for a nonexistent character id", %{user_a: user_a} do
assert {:error, :not_found} =
HandlerAuth.authorize_character(Ecto.UUID.generate(), user_a.id)
end
end
describe "authorize_ping/2" do
setup do
user = create_user()
character_a = create_character(%{user_id: user.id})
character_b = create_character(%{user_id: user.id})
map_a = create_map(%{owner_id: character_a.id})
map_b = create_map(%{owner_id: character_b.id})
system_a =
create_map_system(map_a.id, %{solar_system_id: 30_000_142, position_x: 0, position_y: 0})
{:ok, ping_a} =
Ash.create(WandererApp.Api.MapPing, %{
map_id: map_a.id,
system_id: system_a.id,
character_id: character_a.id,
type: 0,
message: "test"
})
{:ok, map_a: map_a, map_b: map_b, ping_a: ping_a}
end
test "returns {:ok, ping} when ping belongs to the given map", %{
map_a: map_a,
ping_a: ping_a
} do
assert {:ok, returned} = HandlerAuth.authorize_ping(ping_a.id, map_a.id)
assert returned.id == ping_a.id
end
test "returns {:error, :not_found} when ping is on a different map", %{
map_b: map_b,
ping_a: ping_a
} do
# Repro for cancel_ping cross-map cancellation.
assert {:error, :not_found} = HandlerAuth.authorize_ping(ping_a.id, map_b.id)
end
end
describe "authorize_system_comment/2" do
setup do
user = create_user()
character_a = create_character(%{user_id: user.id})
character_b = create_character(%{user_id: user.id})
map_a = create_map(%{owner_id: character_a.id})
map_b = create_map(%{owner_id: character_b.id})
system_a =
create_map_system(map_a.id, %{solar_system_id: 30_000_142, position_x: 0, position_y: 0})
{:ok, comment_a} =
Ash.create(WandererApp.Api.MapSystemComment, %{
system_id: system_a.id,
character_id: character_a.id,
text: "hi"
})
{:ok, map_a: map_a, map_b: map_b, comment_a: comment_a}
end
test "returns {:ok, comment} when comment's system belongs to the given map", %{
map_a: map_a,
comment_a: comment_a
} do
assert {:ok, returned} = HandlerAuth.authorize_system_comment(comment_a.id, map_a.id)
assert returned.id == comment_a.id
end
test "returns {:error, :not_found} when comment's system is on a different map", %{
map_b: map_b,
comment_a: comment_a
} do
# Repro for deleteSystemComment cross-map deletion.
assert {:error, :not_found} =
HandlerAuth.authorize_system_comment(comment_a.id, map_b.id)
end
end
describe "authorize_passage/2" do
setup do
user = create_user()
character_a = create_character(%{user_id: user.id})
character_b = create_character(%{user_id: user.id})
map_a = create_map(%{owner_id: character_a.id})
map_b = create_map(%{owner_id: character_b.id})
{:ok, passage_a} =
Ash.create(WandererApp.Api.MapChainPassages, %{
map_id: map_a.id,
character_id: character_a.id,
ship_type_id: 587,
ship_name: "Rifter",
mass: 1_000_000,
solar_system_source_id: 30_000_142,
solar_system_target_id: 30_000_144
})
{:ok, map_a: map_a, map_b: map_b, passage_a: passage_a}
end
test "returns {:ok, passage} when passage belongs to the given map", %{
map_a: map_a,
passage_a: passage_a
} do
assert {:ok, returned} = HandlerAuth.authorize_passage(passage_a.id, map_a.id)
assert returned.id == passage_a.id
end
test "returns {:error, :not_found} when passage is on a different map", %{
map_b: map_b,
passage_a: passage_a
} do
# Repro for update_passage_mass cross-map corruption.
assert {:error, :not_found} = HandlerAuth.authorize_passage(passage_a.id, map_b.id)
end
end
describe "user_owns_character_eve_id?/2" do
test "returns true when eve_id (as integer) matches a character in the list" do
characters = [%{eve_id: "100"}, %{eve_id: "200"}]
assert HandlerAuth.user_owns_character_eve_id?(characters, 100)
end
test "returns true when eve_id (as string) matches a character in the list" do
characters = [%{eve_id: "100"}, %{eve_id: "200"}]
assert HandlerAuth.user_owns_character_eve_id?(characters, "200")
end
test "returns false when eve_id does not match any character" do
# Repro for updateCharacterTracking attempting to untrack someone else's char.
characters = [%{eve_id: "100"}, %{eve_id: "200"}]
refute HandlerAuth.user_owns_character_eve_id?(characters, "999")
end
test "returns false for empty character list" do
refute HandlerAuth.user_owns_character_eve_id?([], "100")
end
end
describe "parse_subscription_period/1" do
test "accepts the form's allowed periods" do
assert {:ok, 1} = HandlerAuth.parse_subscription_period("1")
assert {:ok, 3} = HandlerAuth.parse_subscription_period("3")
assert {:ok, 6} = HandlerAuth.parse_subscription_period("6")
assert {:ok, 12} = HandlerAuth.parse_subscription_period("12")
end
test "rejects negative period (subscribe exploit)" do
# Repro for the negative-period subscription exploit: `period: "-1"`
# previously produced a back-dated `active_till` and flipped the
# `estimated_price * period` calculation to a negative number.
assert {:error, _} = HandlerAuth.parse_subscription_period("-1")
assert {:error, _} = HandlerAuth.parse_subscription_period("-12")
end
test "rejects zero, out-of-range, and trailing-garbage values" do
assert {:error, _} = HandlerAuth.parse_subscription_period("0")
assert {:error, _} = HandlerAuth.parse_subscription_period("2")
assert {:error, _} = HandlerAuth.parse_subscription_period("24")
assert {:error, _} = HandlerAuth.parse_subscription_period("999999999")
assert {:error, _} = HandlerAuth.parse_subscription_period("12x")
assert {:error, _} = HandlerAuth.parse_subscription_period("abc")
assert {:error, _} = HandlerAuth.parse_subscription_period("")
end
test "rejects non-binary input without raising" do
# The pre-fix handler called `String.to_integer` on raw client input
# and crashed the LV process on a non-binary. The parser must accept
# whatever the client sends.
assert {:error, _} = HandlerAuth.parse_subscription_period(nil)
assert {:error, _} = HandlerAuth.parse_subscription_period(1)
assert {:error, _} = HandlerAuth.parse_subscription_period(%{})
end
end
describe "parse_characters_limit/1" do
test "accepts values in the form's allowed range (50..5000)" do
assert {:ok, 50} = HandlerAuth.parse_characters_limit("50")
assert {:ok, 5_000} = HandlerAuth.parse_characters_limit("5000")
assert {:ok, 250} = HandlerAuth.parse_characters_limit(250)
end
test "rejects values outside the allowed range" do
assert {:error, _} = HandlerAuth.parse_characters_limit("0")
assert {:error, _} = HandlerAuth.parse_characters_limit("49")
assert {:error, _} = HandlerAuth.parse_characters_limit("5001")
assert {:error, _} = HandlerAuth.parse_characters_limit("999999999999")
assert {:error, _} = HandlerAuth.parse_characters_limit("-50")
end
test "rejects malformed input without raising" do
assert {:error, _} = HandlerAuth.parse_characters_limit("abc")
assert {:error, _} = HandlerAuth.parse_characters_limit("")
assert {:error, _} = HandlerAuth.parse_characters_limit(nil)
end
end
describe "parse_hubs_limit/1" do
test "accepts values in the form's allowed range (20..50)" do
assert {:ok, 20} = HandlerAuth.parse_hubs_limit("20")
assert {:ok, 50} = HandlerAuth.parse_hubs_limit("50")
end
test "rejects values outside the allowed range" do
assert {:error, _} = HandlerAuth.parse_hubs_limit("19")
assert {:error, _} = HandlerAuth.parse_hubs_limit("51")
assert {:error, _} = HandlerAuth.parse_hubs_limit("0")
assert {:error, _} = HandlerAuth.parse_hubs_limit("-5")
end
test "rejects malformed input without raising" do
assert {:error, _} = HandlerAuth.parse_hubs_limit("abc")
assert {:error, _} = HandlerAuth.parse_hubs_limit(nil)
end
end
end