diff --git a/copi.owasp.org/SECURITY.md b/copi.owasp.org/SECURITY.md new file mode 100644 index 000000000..9c169d013 --- /dev/null +++ b/copi.owasp.org/SECURITY.md @@ -0,0 +1,37 @@ +# Security Policy for Copi + +## Rate Limiting + +Copi implements IP-based rate limiting to protect against CAPEC-212 (Functionality Misuse) attacks and ensure service availability. + +## Protected Actions + +1. **Game Creation**: Limited to prevent abuse + - Default: 10 games per IP per hour + - Configurable via `MAX_GAMES_PER_IP` and `GAME_CREATION_WINDOW_SECONDS` + +2. **Player Creation**: Separate limit from game creation + - Default: 20 players per IP per hour + - Configurable via `MAX_PLAYERS_PER_IP` and `PLAYER_CREATION_WINDOW_SECONDS` + +3. **WebSocket Connections**: Prevents connection flooding + - Default: 50 connections per IP per 5 minutes + - Configurable via `MAX_CONNECTIONS_PER_IP` and `CONNECTION_WINDOW_SECONDS` + +## Configuration + +All limits can be adjusted via environment variables in production: + +```bash +MAX_GAMES_PER_IP=10 +GAME_CREATION_WINDOW_SECONDS=3600 +MAX_PLAYERS_PER_IP=20 +PLAYER_CREATION_WINDOW_SECONDS=3600 +MAX_CONNECTIONS_PER_IP=50 +CONNECTION_WINDOW_SECONDS=300 +``` + +### Reporting Security Issues + +If you discover a security vulnerability, please email security@owasp.org with details. +Do not create public GitHub issues for security problems. diff --git a/copi.owasp.org/config/runtime.exs b/copi.owasp.org/config/runtime.exs index 105368178..8ab41ba49 100644 --- a/copi.owasp.org/config/runtime.exs +++ b/copi.owasp.org/config/runtime.exs @@ -119,3 +119,35 @@ if config_env() == :prod do # # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. end + +# Rate Limiter Configuration (runtime - can be changed via environment variables) +env_integer = fn var, default -> + case System.get_env(var) do + nil -> + default + + value -> + case Integer.parse(value) do + {int, ""} -> + int + + _ -> + raise ArgumentError, + "environment variable #{var} must be an integer, got: #{inspect(value)}" + end + end +end + +config :copi, Copi.RateLimiter, + # Maximum number of games that can be created from a single IP in the time window + max_games_per_ip: env_integer.("MAX_GAMES_PER_IP", 10), + # Time window in seconds for game creation rate limiting (default: 1 hour) + game_creation_window_seconds: env_integer.("GAME_CREATION_WINDOW_SECONDS", 3600), + # Maximum number of players that can be created from a single IP in the time window + max_players_per_ip: env_integer.("MAX_PLAYERS_PER_IP", 20), + # Time window in seconds for player creation rate limiting (default: 1 hour) + player_creation_window_seconds: env_integer.("PLAYER_CREATION_WINDOW_SECONDS", 3600), + # Maximum number of WebSocket connections from a single IP in the time window + max_connections_per_ip: env_integer.("MAX_CONNECTIONS_PER_IP", 50), + # Time window in seconds for connection rate limiting (default: 5 minutes) + connection_window_seconds: env_integer.("CONNECTION_WINDOW_SECONDS", 300) diff --git a/copi.owasp.org/lib/copi/application.ex b/copi.owasp.org/lib/copi/application.ex index b6493a6a5..bc1463786 100644 --- a/copi.owasp.org/lib/copi/application.ex +++ b/copi.owasp.org/lib/copi/application.ex @@ -15,6 +15,8 @@ defmodule Copi.Application do {Phoenix.PubSub, name: Copi.PubSub}, # Start the DNS clustering {DNSCluster, query: Application.get_env(:copi, :dns_cluster_query) || :ignore}, + # Start the Rate Limiter for security (CAPEC-212 protection) + Copi.RateLimiter, # Start the Endpoint (http/https) CopiWeb.Endpoint # Start a worker by calling: Copi.Worker.start_link(arg) diff --git a/copi.owasp.org/lib/copi/rate_limiter.ex b/copi.owasp.org/lib/copi/rate_limiter.ex new file mode 100644 index 000000000..92fb5eb73 --- /dev/null +++ b/copi.owasp.org/lib/copi/rate_limiter.ex @@ -0,0 +1,229 @@ +defmodule Copi.RateLimiter do + @moduledoc """ + Rate limiter to prevent abuse by limiting requests per IP address. + + This module implements rate limiting for game creation, player creation, and user connections + to protect against CAPEC 212 (Functionality Misuse) attacks. + """ + + use GenServer + require Logger + + @cleanup_interval :timer.minutes(5) + + # Client API + + @doc """ + Starts the rate limiter GenServer. + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Checks if a request from the given IP for the specified action should be allowed. + + Returns `{:ok, remaining}` if allowed, `{:error, :rate_limited, retry_after}` if blocked. + """ + def check_rate(ip_address, action) when action in [:game_creation, :player_creation, :connection] do + GenServer.call(__MODULE__, {:check_rate, ip_address, action}) + end + + @doc """ + Atomically checks and records a rate limit action in a single operation. + This prevents race conditions where multiple concurrent requests could bypass the limit. + + Returns `{:ok, remaining}` if allowed (and records the action), + `{:error, :rate_limited, retry_after}` if blocked (does not record). + """ + def check_and_record(ip_address, action) when action in [:game_creation, :player_creation, :connection] do + GenServer.call(__MODULE__, {:check_and_record, ip_address, action}) + end + + @doc """ + Records a successful action for rate limiting tracking. + """ + def record_action(ip_address, action) when action in [:game_creation, :player_creation, :connection] do + GenServer.cast(__MODULE__, {:record_action, ip_address, action}) + end + + @doc """ + Clears all rate limit data for an IP address (useful for testing). + """ + def clear_ip(ip_address) do + GenServer.cast(__MODULE__, {:clear_ip, ip_address}) + end + + @doc """ + Gets current rate limit configuration. + """ + def get_config do + GenServer.call(__MODULE__, :get_config) + end + + # Server callbacks + + @impl true + def init(_opts) do + # Schedule periodic cleanup + schedule_cleanup() + + config = %{ + game_creation: %{ + max_requests: get_env(:max_games_per_ip, 10), + window_seconds: get_env(:game_creation_window_seconds, 3600) + }, + player_creation: %{ + max_requests: get_env(:max_players_per_ip, 20), + window_seconds: get_env(:player_creation_window_seconds, 3600) + }, + connection: %{ + max_requests: get_env(:max_connections_per_ip, 50), + window_seconds: get_env(:connection_window_seconds, 300) + } + } + + state = %{ + requests: %{}, + config: config + } + + Logger.info("RateLimiter started with config: #{inspect(config)}") + + {:ok, state} + end + + @impl true + def handle_call({:check_rate, ip_address, action}, _from, state) do + now = System.system_time(:second) + config = state.config[action] + + ip_requests = get_ip_requests(state, ip_address, action) + + # Filter out expired requests + valid_requests = Enum.filter(ip_requests, fn timestamp -> + now - timestamp < config.window_seconds + end) + + count = length(valid_requests) + remaining = max(0, config.max_requests - count) + + if count < config.max_requests do + {:reply, {:ok, remaining}, state} + else + oldest_request = List.first(valid_requests) + retry_after = oldest_request + config.window_seconds - now + + Logger.warning( + "Rate limit exceeded for IP #{inspect(ip_address)}, action: #{action}, " <> + "count: #{count}/#{config.max_requests}, retry_after: #{retry_after}s" + ) + + {:reply, {:error, :rate_limited, retry_after}, state} + end + end + + @impl true + def handle_call({:check_and_record, ip_address, action}, _from, state) do + now = System.system_time(:second) + config = state.config[action] + + ip_requests = get_ip_requests(state, ip_address, action) + + # Filter out expired requests + valid_requests = Enum.filter(ip_requests, fn timestamp -> + now - timestamp < config.window_seconds + end) + + count = length(valid_requests) + remaining = max(0, config.max_requests - count) + + if count < config.max_requests do + # Atomically record the action before returning success + updated_requests = [now | valid_requests] + new_requests = put_in(state.requests, [ip_address, action], updated_requests) + new_state = %{state | requests: new_requests} + + {:reply, {:ok, remaining}, new_state} + else + oldest_request = List.first(valid_requests) + retry_after = oldest_request + config.window_seconds - now + + Logger.warning( + "Rate limit exceeded for IP #{inspect(ip_address)}, action: #{action}, " <> + "count: #{count}/#{config.max_requests}, retry_after: #{retry_after}s" + ) + + {:reply, {:error, :rate_limited, retry_after}, state} + end + end + + @impl true + def handle_call(:get_config, _from, state) do + {:reply, state.config, state} + end + + @impl true + def handle_cast({:record_action, ip_address, action}, state) do + now = System.system_time(:second) + + ip_requests = get_ip_requests(state, ip_address, action) + updated_requests = [now | ip_requests] + + new_requests = put_in( + state.requests, + [ip_address, action], + updated_requests + ) + + {:noreply, %{state | requests: new_requests}} + end + + @impl true + def handle_cast({:clear_ip, ip_address}, state) do + new_requests = Map.delete(state.requests, ip_address) + {:noreply, %{state | requests: new_requests}} + end + + @impl true + def handle_info(:cleanup, state) do + now = System.system_time(:second) + + cleaned_requests = state.requests + |> Enum.map(fn {ip, actions} -> + cleaned_actions = actions + |> Enum.map(fn {action, timestamps} -> + config = state.config[action] + valid_timestamps = Enum.filter(timestamps, fn timestamp -> + now - timestamp < config.window_seconds + end) + {action, valid_timestamps} + end) + |> Enum.filter(fn {_action, timestamps} -> length(timestamps) > 0 end) + |> Map.new() + + {ip, cleaned_actions} + end) + |> Enum.filter(fn {_ip, actions} -> map_size(actions) > 0 end) + |> Map.new() + + schedule_cleanup() + + {:noreply, %{state | requests: cleaned_requests}} + end + + # Private helpers + + defp get_ip_requests(state, ip_address, action) do + get_in(state.requests, [ip_address, action]) || [] + end + + defp schedule_cleanup do + Process.send_after(self(), :cleanup, @cleanup_interval) + end + + defp get_env(key, default) do + Application.get_env(:copi, __MODULE__, []) + |> Keyword.get(key, default) + end +end diff --git a/copi.owasp.org/lib/copi_web/endpoint.ex b/copi.owasp.org/lib/copi_web/endpoint.ex index 54c4eca5c..c258c7121 100644 --- a/copi.owasp.org/lib/copi_web/endpoint.ex +++ b/copi.owasp.org/lib/copi_web/endpoint.ex @@ -12,8 +12,8 @@ defmodule CopiWeb.Endpoint do ] socket "/live", Phoenix.LiveView.Socket, - websocket: [timeout: 45_000, connect_info: [session: @session_options]], - longpoll: [connect_info: [session: @session_options]] + websocket: [timeout: 45_000, connect_info: [:peer_data, session: @session_options]], + longpoll: [connect_info: [:peer_data, session: @session_options]] # Serve at "/" the static files from "priv/static" directory. # diff --git a/copi.owasp.org/lib/copi_web/helpers/ip_helper.ex b/copi.owasp.org/lib/copi_web/helpers/ip_helper.ex new file mode 100644 index 000000000..b0dfe3918 --- /dev/null +++ b/copi.owasp.org/lib/copi_web/helpers/ip_helper.ex @@ -0,0 +1,37 @@ +defmodule CopiWeb.Helpers.IPHelper do + @moduledoc """ + Helper functions for extracting and formatting IP addresses from socket connections. + """ + + @doc """ + Extracts the IP address from a LiveView socket connection. + + Returns a string representation of the IP address (IPv4 or IPv6). + Raises an error if the IP address cannot be determined, as this should never + happen in a properly configured backend environment. + + ## Examples + + iex> get_connect_ip(socket) + "192.168.1.1" + + iex> get_connect_ip(socket) + "2001:db8::1" + """ + def get_connect_ip(socket) do + case Phoenix.LiveView.get_connect_info(socket, :peer_data) do + %{address: address} when is_tuple(address) -> + # Use Erlang's :inet.ntoa for proper IPv4/IPv6 formatting + address + |> :inet.ntoa() + |> to_string() + + nil -> + raise "Unable to determine IP address from socket connection. peer_data is nil. " <> + "Ensure endpoint.ex has :peer_data in connect_info list." + + other -> + raise "Unexpected peer_data format: #{inspect(other)}" + end + end +end diff --git a/copi.owasp.org/lib/copi_web/live/game_live/create_game_form.ex b/copi.owasp.org/lib/copi_web/live/game_live/create_game_form.ex index de424483f..7b6090834 100644 --- a/copi.owasp.org/lib/copi_web/live/game_live/create_game_form.ex +++ b/copi.owasp.org/lib/copi_web/live/game_live/create_game_form.ex @@ -4,6 +4,7 @@ defmodule CopiWeb.GameLive.CreateGameForm do alias Copi.Cornucopia alias Copi.Cornucopia.Game alias CopiWeb.GameLive.GameFormHelpers, as: GameFormHelpers + alias CopiWeb.Helpers.IPHelper @impl true def render(assigns) do @@ -107,15 +108,33 @@ defmodule CopiWeb.GameLive.CreateGameForm do end defp save_game(socket, :new, game_params) do - case Cornucopia.create_game(game_params) do - {:ok, game} -> + # Get the IP address for rate limiting + ip_address = IPHelper.get_connect_ip(socket) + + # Atomically check and record rate limit + case Copi.RateLimiter.check_and_record(ip_address, :game_creation) do + {:ok, _remaining} -> + case Cornucopia.create_game(game_params) do + {:ok, game} -> + {:noreply, + socket + |> put_flash(:info, "Game created successfully") + |> push_navigate(to: ~p"/games/#{game.id}")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + + {:error, :rate_limited, retry_after} -> {:noreply, socket - |> put_flash(:info, "Game created successfully") - |> push_navigate(to: ~p"/games/#{game.id}")} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} + |> put_flash( + :error, + "Rate limit exceeded. Too many games created from your IP address. " <> + "Please try again in #{retry_after} seconds. " <> + "This limit helps ensure service availability for all users." + ) + |> assign_form(socket.assigns.form.source)} end end end diff --git a/copi.owasp.org/lib/copi_web/live/game_live/index.ex b/copi.owasp.org/lib/copi_web/live/game_live/index.ex index 4724ae086..f5cdb29c4 100644 --- a/copi.owasp.org/lib/copi_web/live/game_live/index.ex +++ b/copi.owasp.org/lib/copi_web/live/game_live/index.ex @@ -4,10 +4,31 @@ defmodule CopiWeb.GameLive.Index do alias Copi.Cornucopia alias Copi.Cornucopia.Game + alias CopiWeb.Helpers.IPHelper @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, :games, nil)} + # Rate limit WebSocket connections + ip_address = IPHelper.get_connect_ip(socket) + + case Copi.RateLimiter.check_and_record(ip_address, :connection) do + {:ok, _remaining} -> + if connected?(socket) do + Phoenix.PubSub.subscribe(Copi.PubSub, "games") + end + + {:ok, assign(socket, games: list_games())} + + {:error, :rate_limited, retry_after} -> + {:ok, + socket + |> put_flash( + :error, + "Rate limit exceeded. Too many connections from your IP address. " <> + "Please try again in #{retry_after} seconds." + ) + |> assign(games: [])} + end end @impl true diff --git a/copi.owasp.org/lib/copi_web/live/player_live/form_component.ex b/copi.owasp.org/lib/copi_web/live/player_live/form_component.ex index 4e2f523f9..7205adb76 100644 --- a/copi.owasp.org/lib/copi_web/live/player_live/form_component.ex +++ b/copi.owasp.org/lib/copi_web/live/player_live/form_component.ex @@ -3,6 +3,7 @@ defmodule CopiWeb.PlayerLive.FormComponent do use Phoenix.Component alias Copi.Cornucopia + alias CopiWeb.Helpers.IPHelper @impl true @@ -74,19 +75,36 @@ defmodule CopiWeb.PlayerLive.FormComponent do end defp save_player(socket, :new, player_params) do - case Cornucopia.create_player(player_params) do - {:ok, player} -> - - {:ok, updated_game} = Cornucopia.Game.find(socket.assigns.player.game_id) - CopiWeb.Endpoint.broadcast(topic(updated_game.id), "game:updated", updated_game) - + # Get the IP address for rate limiting + ip_address = IPHelper.get_connect_ip(socket) + + # Atomically check and record rate limit + case Copi.RateLimiter.check_and_record(ip_address, :player_creation) do + {:ok, _remaining} -> + case Cornucopia.create_player(player_params) do + {:ok, player} -> + {:ok, updated_game} = Cornucopia.Game.find(socket.assigns.player.game_id) + CopiWeb.Endpoint.broadcast(topic(updated_game.id), "game:updated", updated_game) + + {:noreply, + socket + |> assign(:game, updated_game) + |> push_navigate(to: ~p"/games/#{player.game_id}/players/#{player.id}")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + + {:error, :rate_limited, retry_after} -> {:noreply, socket - |> assign(:game, updated_game) - |> push_navigate(to: ~p"/games/#{player.game_id}/players/#{player.id}")} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} + |> put_flash( + :error, + "Rate limit exceeded. Too many players created from your IP address. " <> + "Please try again in #{retry_after} seconds. " <> + "This limit helps ensure service availability for all users." + ) + |> assign_form(socket.assigns.form.source)} end end diff --git a/copi.owasp.org/mix.lock b/copi.owasp.org/mix.lock index 9e50a1d21..dc68f80e9 100644 --- a/copi.owasp.org/mix.lock +++ b/copi.owasp.org/mix.lock @@ -20,7 +20,7 @@ "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, - "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized"]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, diff --git a/copi.owasp.org/test/copi/rate_limiter_test.exs b/copi.owasp.org/test/copi/rate_limiter_test.exs new file mode 100644 index 000000000..94793db95 --- /dev/null +++ b/copi.owasp.org/test/copi/rate_limiter_test.exs @@ -0,0 +1,183 @@ +defmodule Copi.RateLimiterTest do + use ExUnit.Case + + alias Copi.RateLimiter + + setup do + # Start the RateLimiter for testing + {:ok, pid} = RateLimiter.start_link([]) + + # Clear any existing state + RateLimiter.clear_ip("127.0.0.1") + + on_exit(fn -> + if Process.alive?(pid), do: GenServer.stop(pid) + end) + + :ok + end + + describe "game creation rate limiting" do + test "allows requests under the limit" do + ip = "127.0.0.1" + + # First request should be allowed + assert {:ok, remaining} = RateLimiter.check_rate(ip, :game_creation) + assert remaining >= 0 + + RateLimiter.record_action(ip, :game_creation) + + # Second request should still be allowed + assert {:ok, _remaining} = RateLimiter.check_rate(ip, :game_creation) + end + + test "blocks requests over the limit" do + ip = "192.168.1.100" + config = RateLimiter.get_config() + max_games = config.game_creation.max_requests + + # Make max_requests number of game creations + for _i <- 1..max_games do + assert {:ok, _remaining} = RateLimiter.check_rate(ip, :game_creation) + RateLimiter.record_action(ip, :game_creation) + end + + # Next request should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_rate(ip, :game_creation) + assert retry_after > 0 + end + + test "different IPs have independent limits" do + ip1 = "10.0.0.1" + ip2 = "10.0.0.2" + config = RateLimiter.get_config() + max_games = config.game_creation.max_requests + + # Exhaust limit for ip1 + for _i <- 1..max_games do + RateLimiter.check_rate(ip1, :game_creation) + RateLimiter.record_action(ip1, :game_creation) + end + + # ip1 should be blocked + assert {:error, :rate_limited, _} = RateLimiter.check_rate(ip1, :game_creation) + + # ip2 should still be allowed + assert {:ok, _remaining} = RateLimiter.check_rate(ip2, :game_creation) + end + end + + describe "connection rate limiting" do + test "allows connections under the limit" do + ip = "172.16.0.1" + + assert {:ok, remaining} = RateLimiter.check_rate(ip, :connection) + assert remaining >= 0 + + RateLimiter.record_action(ip, :connection) + + assert {:ok, _remaining} = RateLimiter.check_rate(ip, :connection) + end + + test "blocks connections over the limit" do + ip = "172.16.0.2" + config = RateLimiter.get_config() + max_connections = config.connection.max_requests + + # Make max_requests number of connections + for _i <- 1..max_connections do + assert {:ok, _remaining} = RateLimiter.check_rate(ip, :connection) + RateLimiter.record_action(ip, :connection) + end + + # Next connection should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_rate(ip, :connection) + assert retry_after > 0 + end + end + + describe "player creation rate limiting" do + test "allows player creation under the limit" do + ip = "192.168.2.1" + + assert {:ok, remaining} = RateLimiter.check_rate(ip, :player_creation) + assert remaining >= 0 + + RateLimiter.record_action(ip, :player_creation) + + assert {:ok, _remaining} = RateLimiter.check_rate(ip, :player_creation) + end + + test "blocks player creation over the limit" do + ip = "192.168.2.2" + config = RateLimiter.get_config() + max_players = config.player_creation.max_requests + + # Make max_requests number of player creations + for _i <- 1..max_players do + assert {:ok, _remaining} = RateLimiter.check_rate(ip, :player_creation) + RateLimiter.record_action(ip, :player_creation) + end + + # Next request should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_rate(ip, :player_creation) + assert retry_after > 0 + end + + test "player creation limit is separate from game creation limit" do + ip = "192.168.2.3" + config = RateLimiter.get_config() + max_games = config.game_creation.max_requests + + # Exhaust game creation limit + for _i <- 1..max_games do + RateLimiter.check_rate(ip, :game_creation) + RateLimiter.record_action(ip, :game_creation) + end + + # Game creation should be blocked + assert {:error, :rate_limited, _} = RateLimiter.check_rate(ip, :game_creation) + + # Player creation should still be allowed (separate limit) + assert {:ok, _remaining} = RateLimiter.check_rate(ip, :player_creation) + end + end + + describe "configuration" do + test "returns current configuration" do + config = RateLimiter.get_config() + + assert is_map(config) + assert Map.has_key?(config, :game_creation) + assert Map.has_key?(config, :player_creation) + assert Map.has_key?(config, :connection) + + assert config.game_creation.max_requests > 0 + assert config.game_creation.window_seconds > 0 + + assert config.player_creation.max_requests > 0 + assert config.player_creation.window_seconds > 0 + + assert config.connection.max_requests > 0 + assert config.connection.window_seconds > 0 + end + end + + describe "IP clearing" do + test "clears rate limit data for an IP" do + ip = "10.20.30.40" + + # Record some actions + RateLimiter.record_action(ip, :game_creation) + RateLimiter.record_action(ip, :connection) + + # Clear the IP + RateLimiter.clear_ip(ip) + + # Should be able to make full limit of requests again + config = RateLimiter.get_config() + assert {:ok, remaining} = RateLimiter.check_rate(ip, :game_creation) + assert remaining == config.game_creation.max_requests + end + end +end