diff --git a/copi.owasp.org/RATE_LIMITING_IMPLEMENTATION.md b/copi.owasp.org/RATE_LIMITING_IMPLEMENTATION.md new file mode 100644 index 000000000..d3bd75112 --- /dev/null +++ b/copi.owasp.org/RATE_LIMITING_IMPLEMENTATION.md @@ -0,0 +1,108 @@ +# Rate Limiting Implementation for Player Creation + +## Overview +This implementation addresses GitHub Issue #1877 by adding IP-based rate limiting for player creation, separate from the existing game creation rate limit, to protect against CAPEC 212 (Functionality Misuse) attacks. + +## Changes Made + +### 1. Rate Limiter Module (`lib/copi/rate_limiter.ex`) + +**Added player_creation action:** +- Updated `check_rate/2` to accept `:player_creation` action +- Updated `record_action/2` to accept `:player_creation` action +- Added player creation configuration with default limits: + - Maximum players per IP: 20 + - Time window: 3600 seconds (1 hour) + +**Configuration:** +```elixir +player_creation: %{ + max_requests: get_env(:max_players_per_ip, 20), + window_seconds: get_env(:player_creation_window_seconds, 3600) +} +``` + +### 2. Player Form Component (`lib/copi_web/live/player_live/form_component.ex`) + +**Added rate limiting to player creation:** +- Added `get_connect_ip/1` helper function to extract IP address from socket +- Modified `save_player/3` for `:new` action to: + 1. Get the connecting IP address + 2. Check rate limit before creating player + 3. Only create player if rate limit is not exceeded + 4. Record the action after successful creation + 5. Display user-friendly error message if rate limited + +**Error message shown to users:** +``` +"Rate limit exceeded. Too many players created from your IP address. +Please try again in X seconds. This limit helps ensure service +availability for all users." +``` + +### 3. Tests (`test/copi/rate_limiter_test.exs`) + +**Added comprehensive test coverage:** +- Test that player creation is allowed under the limit +- Test that player creation is blocked when limit is exceeded +- Test that player creation limit is separate from game creation limit +- Updated configuration test to verify player_creation config exists + +## Key Features + +### Separate Limits +The player creation rate limit is maintained **separately** from the game creation rate limit. This means: +- An IP that has exhausted its game creation quota can still create players +- An IP that has exhausted its player creation quota can still create games +- Each limit is tracked independently in the GenServer state + +### Configurable Limits +The limits can be configured via application environment: +```elixir +config :copi, Copi.RateLimiter, + max_players_per_ip: 20, + player_creation_window_seconds: 3600 +``` + +### User-Friendly Error Handling +When rate limited, users receive: +- Clear explanation of why they were blocked +- Time until they can try again (retry_after in seconds) +- Explanation that this protects service availability + +## Security Benefits + +1. **CAPEC 212 Mitigation**: Prevents functionality misuse by limiting the rate of player creation from a single IP +2. **DoS Protection**: Helps maintain service availability under attack +3. **Resource Conservation**: Prevents database and system resource exhaustion +4. **Granular Control**: Separate limits allow fine-tuned protection for different actions + +## Default Limits Summary + +| Action | Max Requests | Time Window | +|--------|--------------|-------------| +| Game Creation | 10 | 1 hour | +| Player Creation | 20 | 1 hour | +| Connection | 50 | 5 minutes | + +## Testing + +Run the test suite: +```bash +mix test test/copi/rate_limiter_test.exs +``` + +All tests should pass, including the new player creation rate limiting tests. + +## Future Enhancements + +As mentioned in the issue, if this is still insufficient, the next step would be: +- Implement authentication +- Associate rate limits with user accounts in addition to IP addresses +- Track browser fingerprints along with IP addresses + +--- + +**Issue Reference:** OWASP/cornucopia#1877 +**Related Security Control:** CAPEC-212 (Functionality Misuse) +**Implemented by:** @immortal71 diff --git a/copi.owasp.org/SECURITY.md b/copi.owasp.org/SECURITY.md new file mode 100644 index 000000000..0ea6d7733 --- /dev/null +++ b/copi.owasp.org/SECURITY.md @@ -0,0 +1,203 @@ +# Security Implementation for Copi + +This document describes the security measures implemented in Copi to protect against abuse and ensure service availability. + +## Overview + +Copi has implemented IP-based rate limiting to protect against **CAPEC 212 (Functionality Misuse)** attacks. These protections help ensure that the service remains available for all legitimate users by preventing a single source from overwhelming the system. + +## Implemented Protections + +### 1. Game Creation Rate Limiting + +**Purpose**: Prevents a single IP address from creating an excessive number of games, which could lead to database exhaustion or denial of service. + +**Default Configuration**: +- Maximum games per IP: 10 +- Time window: 3600 seconds (1 hour) + +**Behavior**: +- Tracks game creation attempts by IP address +- When the limit is exceeded, users receive a clear error message +- The limit resets after the time window expires +- Different IP addresses have independent limits + +**Configuration**: +```bash +export MAX_GAMES_PER_IP=10 +export GAME_CREATION_WINDOW_SECONDS=3600 +``` + +### 2. WebSocket Connection Rate Limiting + +**Purpose**: Prevents a single IP address from opening excessive WebSocket connections, which could exhaust server resources. + +**Default Configuration**: +- Maximum connections per IP: 50 +- Time window: 300 seconds (5 minutes) + +**Behavior**: +- Tracks connection attempts by IP address +- When the limit is exceeded, connections are rejected with an error message +- The limit resets after the time window expires +- Does not affect existing active connections + +**Configuration**: +```bash +export MAX_CONNECTIONS_PER_IP=50 +export CONNECTION_WINDOW_SECONDS=300 +``` + +## Technical Implementation + +### Architecture + +The rate limiting system consists of several components: + +1. **Copi.RateLimiter** (`lib/copi/rate_limiter.ex`) + - GenServer that maintains rate limit state + - Tracks requests per IP address and action type + - Automatically cleans up expired entries every 5 minutes + - Provides a simple API for checking and recording actions + +2. **CopiWeb.Plugs.RateLimiter** (`lib/copi_web/plugs/rate_limiter.ex`) + - Plug for HTTP request rate limiting + - Extracts IP addresses from connections + - Handles X-Forwarded-For headers for reverse proxies + - Returns proper HTTP 429 status codes when limits are exceeded + +3. **LiveView Integration** + - Game creation rate limiting in `CopiWeb.GameLive.CreateGameForm` + - Connection rate limiting in `CopiWeb.GameLive.Index` + - Provides user-friendly error messages + +### IP Address Handling + +The system correctly handles: +- IPv4 addresses (e.g., 192.168.1.1) +- IPv6 addresses (e.g., 2001:db8::1) +- X-Forwarded-For headers (for reverse proxy deployments) +- Multiple IP addresses in X-Forwarded-For (uses the first one) + +### Rate Limit Response Headers + +When rate limiting is active, the following headers are included in responses: + +- `X-RateLimit-Remaining`: Number of requests remaining in the current window +- `Retry-After`: Seconds until the rate limit resets (only included when rate limited) + +### Error Messages + +Users who exceed rate limits receive clear, informative error messages: + +``` +Rate limit exceeded. Too many games created from your IP address. +Please try again in 3600 seconds. +This limit helps ensure service availability for all users. +``` + +## Deployment Considerations + +### Reverse Proxy Configuration + +If deploying behind a reverse proxy (nginx, HAProxy, Cloudflare, etc.), ensure the real client IP is passed through: + +**Nginx**: +```nginx +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +``` + +**Apache**: +```apache +RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s" +``` + +**Cloudflare**: +Cloudflare automatically sets the CF-Connecting-IP header. The X-Forwarded-For header should also be present. + +### Monitoring + +Monitor the following metrics to detect abuse or adjust rate limits: + +- Rate limit rejections (logged as warnings) +- Rate limit state size (number of tracked IPs) +- 429 HTTP responses +- Flash messages about rate limiting + +### Adjusting Rate Limits + +Rate limits can be adjusted based on your deployment needs: + +**For Development/Testing**: +```bash +export MAX_GAMES_PER_IP=100 +export MAX_CONNECTIONS_PER_IP=200 +``` + +**For High-Traffic Production**: +```bash +export MAX_GAMES_PER_IP=5 +export GAME_CREATION_WINDOW_SECONDS=7200 # 2 hours +export MAX_CONNECTIONS_PER_IP=30 +export CONNECTION_WINDOW_SECONDS=600 # 10 minutes +``` + +**For Low-Traffic/Internal Use**: +```bash +export MAX_GAMES_PER_IP=50 +export MAX_CONNECTIONS_PER_IP=100 +``` + +## Testing + +The implementation includes comprehensive tests: + +- **Unit tests** for the RateLimiter GenServer (`test/copi/rate_limiter_test.exs`) +- **Integration tests** for the RateLimiter Plug (`test/copi_web/plugs/rate_limiter_test.exs`) + +Run tests: +```bash +mix test +``` + +Run specific rate limiter tests: +```bash +mix test test/copi/rate_limiter_test.exs +mix test test/copi_web/plugs/rate_limiter_test.exs +``` + +## Future Enhancements + +Potential future security enhancements (not currently implemented): + +1. **Authentication Integration** + - Associate rate limits with authenticated users + - Different limits for authenticated vs. anonymous users + - Per-user rate limiting instead of just per-IP + +2. **Geographic Rate Limiting** + - Different limits based on geographic location + - Blocking or stricter limits for high-risk regions + +3. **Adaptive Rate Limiting** + - Automatically adjust limits based on system load + - Stricter limits during high traffic periods + +4. **CAPTCHA Integration** + - Require CAPTCHA after repeated rate limit violations + - Optional CAPTCHA for game creation + +5. **Rate Limit Dashboard** + - Admin interface to view current rate limit state + - Ability to manually block/unblock IPs + - Real-time monitoring of rate limit violations + +## Reporting Security Issues + +If you discover a security vulnerability in Copi, please report it to the OWASP Cornucopia team through the [GitHub Security Advisories](https://github.com/OWASP/cornucopia/security/advisories) page. + +## References + +- [CAPEC-212: Functionality Misuse](https://capec.mitre.org/data/definitions/212.html) +- [OWASP API Security Top 10 - API4:2023 Unrestricted Resource Consumption](https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/) +- [Phoenix Framework Security](https://hexdocs.pm/phoenix/security.html) diff --git a/copi.owasp.org/config/config.exs b/copi.owasp.org/config/config.exs index 4e51421d4..a27935fda 100644 --- a/copi.owasp.org/config/config.exs +++ b/copi.owasp.org/config/config.exs @@ -27,6 +27,17 @@ config :copi, CopiWeb.Endpoint, config :copi, env: Mix.env() +# Configure rate limiting to prevent abuse (CAPEC 212 - Functionality Misuse) +config :copi, Copi.RateLimiter, + # Maximum number of games that can be created from a single IP in the time window + max_games_per_ip: System.get_env("MAX_GAMES_PER_IP", "10") |> String.to_integer(), + # Time window in seconds for game creation rate limiting (default: 1 hour) + game_creation_window_seconds: System.get_env("GAME_CREATION_WINDOW_SECONDS", "3600") |> String.to_integer(), + # Maximum number of WebSocket connections from a single IP in the time window + max_connections_per_ip: System.get_env("MAX_CONNECTIONS_PER_IP", "50") |> String.to_integer(), + # Time window in seconds for connection rate limiting (default: 5 minutes) + connection_window_seconds: System.get_env("CONNECTION_WINDOW_SECONDS", "300") |> String.to_integer() + # Configure tailwind (the version is required) config :tailwind, version: "3.4.0", diff --git a/copi.owasp.org/config/test.exs b/copi.owasp.org/config/test.exs index 5f146b191..9cc69d217 100644 --- a/copi.owasp.org/config/test.exs +++ b/copi.owasp.org/config/test.exs @@ -7,7 +7,7 @@ import Config # Run `mix help test` for more information. config :copi, Copi.Repo, username: "postgres", - password: System.get_env("POSTGRES_TEST_PWD"), + password: System.get_env("POSTGRES_TEST_PWD") || "postgres", database: "copi_test#{System.get_env("MIX_TEST_PARTITION")}", hostname: "localhost", pool: Ecto.Adapters.SQL.Sandbox @@ -18,5 +18,11 @@ config :copi, CopiWeb.Endpoint, http: [port: 4002], server: false +# Configure rate limiter with very high limits for testing to prevent blocking test execution +config :copi, Copi.RateLimiter, + max_games_per_ip: 100_000, + max_players_per_ip: 100_000, + max_connections_per_ip: 100_000 + # Print only warnings and errors during test config :logger, level: :warning diff --git a/copi.owasp.org/lib/copi/application.ex b/copi.owasp.org/lib/copi/application.ex index b6493a6a5..0d9ba8bf8 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 + 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..d8baf6faa --- /dev/null +++ b/copi.owasp.org/lib/copi/rate_limiter.ex @@ -0,0 +1,233 @@ +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 """ + 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 """ + Atomically checks and records an action if allowed. + This prevents race conditions where multiple requests could bypass the rate limit. + + Returns `{:ok, remaining}` if allowed and recorded, `{:error, :rate_limited, retry_after}` if blocked. + """ + 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 """ + 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 - 1) # -1 because we're about to record + + if count < config.max_requests do + # Atomically record the action before returning success + updated_requests = [now | valid_requests] + new_requests = Map.update( + state.requests, + ip_address, + %{action => updated_requests}, + fn actions -> Map.put(actions, action, updated_requests) end + ) + + {:reply, {:ok, remaining}, %{state | requests: new_requests}} + 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 = Map.update( + state.requests, + ip_address, + %{action => updated_requests}, + fn actions -> Map.put(actions, action, updated_requests) end + ) + + {: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..2d64f820d 100644 --- a/copi.owasp.org/lib/copi_web/endpoint.ex +++ b/copi.owasp.org/lib/copi_web/endpoint.ex @@ -12,8 +12,11 @@ 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: [session: @session_options, peer_data: true, x_headers: ["x-forwarded-for"]] + ], + longpoll: [connect_info: [session: @session_options, peer_data: true, x_headers: ["x-forwarded-for"]]] # 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..411e77dfe --- /dev/null +++ b/copi.owasp.org/lib/copi_web/helpers/ip_helper.ex @@ -0,0 +1,41 @@ +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: {a, b, c, d}} -> + # IPv4 address + "#{a}.#{b}.#{c}.#{d}" + + %{address: {a, b, c, d, e, f, g, h}} -> + # IPv6 address - format as colon-separated hex + [a, b, c, d, e, f, g, h] + |> Enum.map(&Integer.to_string(&1, 16)) + |> Enum.join(":") + + nil -> + # peer_data not available (e.g., during disconnected mount or test environment) + "127.0.0.1" + + 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 a7591266a..645bc1cde 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 @@ -108,15 +108,35 @@ 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 (passed from parent via assigns) + ip_address = Map.get(socket.assigns, :ip_address, "127.0.0.1") + + # Check and record rate limit atomically + 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..748b5ba89 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 @@ -7,7 +7,30 @@ defmodule CopiWeb.GameLive.Index do @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, :games, nil)} + if connected?(socket) do + # Rate limit WebSocket connections (only on connected mount) + ip_address = CopiWeb.Helpers.IPHelper.get_connect_ip(socket) + Phoenix.PubSub.subscribe(Copi.PubSub, "games") + + case Copi.RateLimiter.check_and_record(ip_address, :connection) do + {:ok, _remaining} -> + {:ok, assign(socket, games: nil, ip_address: ip_address)} + + {:error, :rate_limited, retry_after} -> + {:ok, + socket + |> put_flash( + :error, + "Connection rate limit exceeded. Too many connections from your IP address. " <> + "Please try again in #{retry_after} seconds." + ) + |> assign(games: nil, ip_address: ip_address)} + end + else + # Disconnected mount (initial static render) - no WebSocket yet + # Assign games: nil to match original behavior expected by templates + {:ok, assign(socket, games: nil, ip_address: "127.0.0.1")} + end end @impl true diff --git a/copi.owasp.org/lib/copi_web/live/game_live/index.html.heex b/copi.owasp.org/lib/copi_web/live/game_live/index.html.heex index 2c14274e1..4005bad28 100644 --- a/copi.owasp.org/lib/copi_web/live/game_live/index.html.heex +++ b/copi.owasp.org/lib/copi_web/live/game_live/index.html.heex @@ -6,6 +6,7 @@ action={@live_action} game={@game} patch={~p"/games"} + ip_address={@ip_address} /> 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 0568e04d3..d012a806f 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 @@ -74,23 +74,42 @@ 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 (passed from parent via assigns) + ip_address = Map.get(socket.assigns, :ip_address, "127.0.0.1") + + # Check and record rate limit atomically + 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 def topic(game_id) do "game:#{game_id}" end + end diff --git a/copi.owasp.org/lib/copi_web/live/player_live/index.ex b/copi.owasp.org/lib/copi_web/live/player_live/index.ex index d13715d41..fc678e321 100644 --- a/copi.owasp.org/lib/copi_web/live/player_live/index.ex +++ b/copi.owasp.org/lib/copi_web/live/player_live/index.ex @@ -6,7 +6,14 @@ defmodule CopiWeb.PlayerLive.Index do @impl true def mount(%{"game_id" => game_id}, _session, socket) do - {:ok, assign(socket, players: list_players(game_id), game: Cornucopia.get_game!(game_id))} + ip_address = + if connected?(socket) do + CopiWeb.Helpers.IPHelper.get_connect_ip(socket) + else + "127.0.0.1" + end + + {:ok, assign(socket, players: list_players(game_id), game: Cornucopia.get_game!(game_id), ip_address: ip_address)} end @impl true diff --git a/copi.owasp.org/lib/copi_web/live/player_live/index.html.heex b/copi.owasp.org/lib/copi_web/live/player_live/index.html.heex index f7e779148..1e3b814bd 100644 --- a/copi.owasp.org/lib/copi_web/live/player_live/index.html.heex +++ b/copi.owasp.org/lib/copi_web/live/player_live/index.html.heex @@ -6,6 +6,7 @@ action={@live_action} player={@player} patch={~p"/games/#{@game.id}"} + ip_address={@ip_address} /> diff --git a/copi.owasp.org/lib/copi_web/plugs/rate_limiter.ex b/copi.owasp.org/lib/copi_web/plugs/rate_limiter.ex new file mode 100644 index 000000000..b8b088b62 --- /dev/null +++ b/copi.owasp.org/lib/copi_web/plugs/rate_limiter.ex @@ -0,0 +1,59 @@ +defmodule CopiWeb.Plugs.RateLimiter do + @moduledoc """ + Plug for rate limiting HTTP requests based on IP address. + """ + + import Plug.Conn + require Logger + + alias Copi.RateLimiter + + def init(opts), do: opts + + def call(conn, opts) do + action = Keyword.get(opts, :action, :game_creation) + ip_address = get_ip_address(conn) + + case RateLimiter.check_rate(ip_address, action) do + {:ok, remaining} -> + RateLimiter.record_action(ip_address, action) + conn + |> put_resp_header("x-ratelimit-remaining", to_string(remaining)) + + {:error, :rate_limited, retry_after} -> + Logger.warning("Rate limit exceeded for IP: #{inspect(ip_address)}, action: #{action}") + + conn + |> put_resp_header("retry-after", to_string(retry_after)) + |> put_resp_header("x-ratelimit-remaining", "0") + |> send_resp(429, rate_limit_message(action, retry_after)) + |> halt() + end + end + + defp get_ip_address(conn) do + # Always use conn.remote_ip, which is set by Plug.RemoteIp if present in the plug pipeline. + # Ensure Plug.RemoteIp is used with a properly configured :proxies list for safe client IP extraction. + case conn.remote_ip do + {a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}" + {a, b, c, d, e, f, g, h} -> + "#{Integer.to_string(a, 16)}:#{Integer.to_string(b, 16)}:#{Integer.to_string(c, 16)}:#{Integer.to_string(d, 16)}:#{Integer.to_string(e, 16)}:#{Integer.to_string(f, 16)}:#{Integer.to_string(g, 16)}:#{Integer.to_string(h, 16)}" + _ -> "unknown" + end + end + + defp rate_limit_message(action, retry_after) do + action_name = case action do + :game_creation -> "game creation" + :connection -> "connections" + _ -> "requests" + end + + """ + Rate limit exceeded for #{action_name}. + Please try again in #{retry_after} seconds. + + This protection is in place to ensure service availability for all users. + """ + end +end 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..afdb1f1c6 --- /dev/null +++ b/copi.owasp.org/test/copi/rate_limiter_test.exs @@ -0,0 +1,210 @@ +defmodule Copi.RateLimiterTest do + use ExUnit.Case, async: false + + alias Copi.RateLimiter + + setup do + # Clear rate limiter state to prevent test interference + # Each test uses a unique IP but we clear it anyway for safety + :ok + end + + describe "game creation rate limiting" do + test "allows requests under the limit" do + ip = "127.0.0.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip) + + # First request should be allowed + assert {:ok, remaining} = RateLimiter.check_and_record(ip, :game_creation) + assert remaining >= 0 + + # Second request should still be allowed + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :game_creation) + end + + test "blocks requests over the limit" do + ip = "192.168.1.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip) + 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_and_record(ip, :game_creation) + end + + # Next request should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_and_record(ip, :game_creation) + assert retry_after > 0 + end + + test "different IPs have independent limits" do + ip1 = "10.0.0.#{:rand.uniform(255)}" + ip2 = "10.0.1.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip1) + RateLimiter.clear_ip(ip2) + config = RateLimiter.get_config() + max_games = config.game_creation.max_requests + + # Exhaust limit for ip1 + for _i <- 1..max_games do + RateLimiter.check_and_record(ip1, :game_creation) + end + + # ip1 should be blocked + assert {:error, :rate_limited, _} = RateLimiter.check_and_record(ip1, :game_creation) + + # ip2 should still be allowed + assert {:ok, _remaining} = RateLimiter.check_and_record(ip2, :game_creation) + end + end + + describe "connection rate limiting" do + test "allows connections under the limit" do + ip = "172.16.0.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip) + + assert {:ok, remaining} = RateLimiter.check_and_record(ip, :connection) + assert remaining >= 0 + + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :connection) + end + + test "blocks connections over the limit" do + ip = "172.16.1.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip) + 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_and_record(ip, :connection) + end + + # Next connection should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_and_record(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.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip) + + assert {:ok, remaining} = RateLimiter.check_and_record(ip, :player_creation) + assert remaining >= 0 + + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :player_creation) + end + + test "blocks player creation over the limit" do + ip = "192.168.3.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip) + 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_and_record(ip, :player_creation) + end + + # Next request should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_and_record(ip, :player_creation) + assert retry_after > 0 + end + + test "player creation limit is separate from game creation limit" do + ip = "192.168.4.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip) + config = RateLimiter.get_config() + max_games = config.game_creation.max_requests + + # Exhaust game creation limit + for _i <- 1..max_games do + RateLimiter.check_and_record(ip, :game_creation) + end + + # Game creation should be blocked + assert {:error, :rate_limited, _} = RateLimiter.check_and_record(ip, :game_creation) + + # Player creation should still be allowed (separate limit) + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :player_creation) + end + end + + describe "rate limit window expiration" do + test "allows requests after window expires" do + ip = "192.168.100.#{:rand.uniform(255)}" + + # Fill up to the limit + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :game_creation) + + # Clear the IP data (simulates window expiration) + RateLimiter.clear_ip(ip) + + # Give the GenServer a moment to process the clear + Process.sleep(10) + + # Request should now be allowed again + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :game_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) + + # Give time for async operation + Process.sleep(10) + + # 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 + + describe "cleanup mechanism" do + test "cleanup runs periodically" do + # This test verifies the cleanup handler exists and can be called + ip = "192.168.99.#{:rand.uniform(255)}" + RateLimiter.clear_ip(ip) + + # Record an action + RateLimiter.record_action(ip, :game_creation) + + # Verify it was recorded + assert {:ok, remaining} = RateLimiter.check_rate(ip, :game_creation) + config = RateLimiter.get_config() + assert remaining < config.game_creation.max_requests + end + end +end diff --git a/copi.owasp.org/test/copi_web/helpers/ip_helper_test.exs b/copi.owasp.org/test/copi_web/helpers/ip_helper_test.exs new file mode 100644 index 000000000..a3d110b38 --- /dev/null +++ b/copi.owasp.org/test/copi_web/helpers/ip_helper_test.exs @@ -0,0 +1,73 @@ +defmodule CopiWeb.Helpers.IPHelperTest do + use CopiWeb.ConnCase + import Phoenix.LiveViewTest + + alias Phoenix.LiveView.Socket + + describe "get_connect_ip/1" do + test "extracts IPv4 address from socket" do + socket = %Socket{ + private: %{ + connect_info: %{ + peer_data: %{address: {192, 168, 1, 100}, port: 12345, ssl_cert: nil} + } + } + } + + assert CopiWeb.Helpers.IPHelper.get_connect_ip(socket) == "192.168.1.100" + end + + test "extracts IPv6 address from socket" do + socket = %Socket{ + private: %{ + connect_info: %{ + peer_data: %{address: {8193, 3512, 0, 0, 0, 0, 0, 1}, port: 12345, ssl_cert: nil} + } + } + } + + assert CopiWeb.Helpers.IPHelper.get_connect_ip(socket) == "2001:DB8:0:0:0:0:0:1" + end + + test "handles localhost IPv4 address" do + socket = %Socket{ + private: %{ + connect_info: %{ + peer_data: %{address: {127, 0, 0, 1}, port: 12345, ssl_cert: nil} + } + } + } + + assert CopiWeb.Helpers.IPHelper.get_connect_ip(socket) == "127.0.0.1" + end + + test "handles different IPv4 addresses" do + test_cases = [ + {{10, 0, 0, 1}, "10.0.0.1"}, + {{172, 16, 254, 1}, "172.16.254.1"} + ] + + for {address, expected} <- test_cases do + socket = %Socket{ + private: %{ + connect_info: %{ + peer_data: %{address: address, port: 12345, ssl_cert: nil} + } + } + } + + assert CopiWeb.Helpers.IPHelper.get_connect_ip(socket) == expected + end + end + + test "returns fallback IP when peer_data is nil" do + socket = %Socket{ + private: %{ + connect_info: %{peer_data: nil} + } + } + + assert CopiWeb.Helpers.IPHelper.get_connect_ip(socket) == "127.0.0.1" + end + end +end diff --git a/copi.owasp.org/test/copi_web/plugs/rate_limiter_test.exs b/copi.owasp.org/test/copi_web/plugs/rate_limiter_test.exs new file mode 100644 index 000000000..4d3e6ec2f --- /dev/null +++ b/copi.owasp.org/test/copi_web/plugs/rate_limiter_test.exs @@ -0,0 +1,60 @@ +defmodule CopiWeb.Plugs.RateLimiterTest do + use CopiWeb.ConnCase, async: true + + alias CopiWeb.Plugs.RateLimiter + alias Copi.RateLimiter, as: RateLimiterServer + + setup do + # Clear any existing state + RateLimiterServer.clear_ip("127.0.0.1") + :ok + end + + describe "rate limiter plug" do + test "allows requests under the limit", %{conn: conn} do + conn = RateLimiter.call(conn, action: :game_creation) + + refute conn.halted + assert get_resp_header(conn, "x-ratelimit-remaining") != [] + end + + test "blocks requests over the limit", %{conn: conn} do + config = RateLimiterServer.get_config() + max_requests = config.game_creation.max_requests + + # Exhaust the rate limit + for _i <- 1..max_requests do + RateLimiterServer.check_rate("127.0.0.1", :game_creation) + RateLimiterServer.record_action("127.0.0.1", :game_creation) + end + + # Next request should be blocked + conn = RateLimiter.call(conn, action: :game_creation) + + assert conn.halted + assert conn.status == 429 + assert get_resp_header(conn, "retry-after") != [] + assert get_resp_header(conn, "x-ratelimit-remaining") == ["0"] + end + + test "sets rate limit headers", %{conn: conn} do + conn = RateLimiter.call(conn, action: :game_creation) + + headers = get_resp_header(conn, "x-ratelimit-remaining") + assert length(headers) > 0 + + [remaining] = headers + assert String.to_integer(remaining) >= 0 + end + + test "handles different actions separately", %{conn: conn} do + # Test game creation + conn1 = RateLimiter.call(conn, action: :game_creation) + refute conn1.halted + + # Test connection (should be independent) + conn2 = RateLimiter.call(conn, action: :connection) + refute conn2.halted + end + end +end diff --git a/copi.owasp.org/test/support/conn_case.ex b/copi.owasp.org/test/support/conn_case.ex index 27384afd1..7972ab793 100644 --- a/copi.owasp.org/test/support/conn_case.ex +++ b/copi.owasp.org/test/support/conn_case.ex @@ -38,6 +38,21 @@ defmodule CopiWeb.ConnCase do Ecto.Adapters.SQL.Sandbox.mode(Copi.Repo, {:shared, self()}) end - {:ok, conn: Phoenix.ConnTest.build_conn()} + conn = Phoenix.ConnTest.build_conn() + + # Set peer data for LiveView tests using @tag peer_ip + conn = if tags[:peer_ip] do + Plug.Conn.put_private(conn, :plug_connect_info, %{ + peer_data: %{ + address: tags[:peer_ip], + port: 12345, + ssl_cert: nil + } + }) + else + conn + end + + {:ok, conn: conn} end end 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..522519e2d --- /dev/null +++ b/copi/owasp.org/test/copi/rate_limiter_test.exs @@ -0,0 +1,204 @@ +defmodule Copi.RateLimiterTest do + use ExUnit.Case, async: false + + alias Copi.RateLimiter + + setup do + # Tests run sequentially and use unique IPs to avoid conflicts + :ok + end + + describe "game creation rate limiting" do + test "allows requests under the limit" do + ip = "127.0.0.#{:rand.uniform(255)}" + + # First request should be allowed + assert {:ok, remaining} = RateLimiter.check_and_record(ip, :game_creation) + assert remaining >= 0 + + # Second request should still be allowed + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :game_creation) + end + + @tag timeout: :infinity + test "blocks requests over the limit" do + ip = "192.168.1.#{:rand.uniform(255)}" + 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_and_record(ip, :game_creation) + end + + # Next request should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_and_record(ip, :game_creation) + assert retry_after > 0 + end + + @tag timeout: :infinity + test "different IPs have independent limits" do + ip1 = "10.0.0.#{:rand.uniform(255)}" + ip2 = "10.0.1.#{:rand.uniform(255)}" + config = RateLimiter.get_config() + max_games = config.game_creation.max_requests + + # Exhaust limit for ip1 + for _i <- 1..max_games do + RateLimiter.check_and_record(ip1, :game_creation) + end + + # ip1 should be blocked + assert {:error, :rate_limited, _} = RateLimiter.check_and_record(ip1, :game_creation) + + # ip2 should still be allowed + assert {:ok, _remaining} = RateLimiter.check_and_record(ip2, :game_creation) + end + end + + describe "connection rate limiting" do + test "allows connections under the limit" do + ip = "172.16.0.#{:rand.uniform(255)}" + + assert {:ok, remaining} = RateLimiter.check_and_record(ip, :connection) + assert remaining >= 0 + + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :connection) + end + + @tag timeout: :infinity + test "blocks connections over the limit" do + ip = "172.16.1.#{:rand.uniform(255)}" + 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_and_record(ip, :connection) + end + + # Next connection should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_and_record(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.#{:rand.uniform(255)}" + + assert {:ok, remaining} = RateLimiter.check_and_record(ip, :player_creation) + assert remaining >= 0 + + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :player_creation) + end + + @tag timeout: :infinity + test "blocks player creation over the limit" do + ip = "192.168.3.#{:rand.uniform(255)}" + 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_and_record(ip, :player_creation) + end + + # Next request should be blocked + assert {:error, :rate_limited, retry_after} = RateLimiter.check_and_record(ip, :player_creation) + assert retry_after > 0 + end + + @tag timeout: :infinity + test "player creation limit is separate from game creation limit" do + ip = "192.168.4.#{:rand.uniform(255)}" + config = RateLimiter.get_config() + max_games = config.game_creation.max_requests + + # Exhaust game creation limit + for _i <- 1..max_games do + RateLimiter.check_and_record(ip, :game_creation) + end + + # Game creation should be blocked + assert {:error, :rate_limited, _} = RateLimiter.check_and_record(ip, :game_creation) + + # Player creation should still be allowed (separate limit) + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :player_creation) + end + end + + describe "rate limit window expiration" do + test "allows requests after window expires" do + ip = "192.168.100.#{:rand.uniform(255)}" + + # Fill up to the limit + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :game_creation) + + # Clear the IP data (simulates window expiration) + RateLimiter.clear_ip(ip) + + # Give the GenServer a moment to process the clear + Process.sleep(10) + + # Request should now be allowed again + assert {:ok, _remaining} = RateLimiter.check_and_record(ip, :game_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) + + # Give time for async operation + Process.sleep(10) + + # 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 + + describe "cleanup mechanism" do + test "cleanup runs periodically" do + # This test verifies the cleanup handler exists and can be called + ip = "192.168.99.#{:rand.uniform(255)}" + + # Record an action + RateLimiter.record_action(ip, :game_creation) + + # Verify it was recorded + assert {:ok, remaining} = RateLimiter.check_rate(ip, :game_creation) + config = RateLimiter.get_config() + assert remaining < config.game_creation.max_requests + end + end +end \ No newline at end of file