-
-
Notifications
You must be signed in to change notification settings - Fork 94
Fix race condition in voting system with unique constraint and safe upsert #2289
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a8f779d
769d544
89d2159
445b1c7
8f14ab6
39b7b4a
49f9e56
f250b13
f30f5a3
3f550a6
9185c5b
162f10d
9fcfae0
ef9e991
2a28e9a
ad04fd2
6fc9d4b
676a30a
d6d07e9
5714610
f73b693
d238087
24a9400
c526913
1878843
e18812a
f05acf3
bbb3a63
6c93cae
6a18849
7634545
447299e
788e801
c800d6b
d02539f
5d6a850
e880c1a
d533510
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| # Security - Rate Limiting Implementation | ||
|
|
||
| ## Overview | ||
|
|
||
| Copi implements IP-based rate limiting to protect against CAPEC-212 (Functionality Misuse) attacks and ensure service availability under potential abuse scenarios. | ||
|
|
||
| ## Rate Limiting Strategy | ||
|
|
||
| The application uses a GenServer-based rate limiter that tracks requests per IP address across different action types. This prevents malicious actors from overwhelming the service while maintaining usability for legitimate users. | ||
|
|
||
| ### Protected Actions | ||
|
|
||
| 1. **Game Creation**: Limited to prevent mass game creation attacks | ||
| 2. **Player Creation**: Limited to prevent player spam | ||
| 3. **WebSocket Connections**: Limited to prevent connection flooding | ||
|
|
||
| ## Default Rate Limits | ||
|
|
||
| | Action | Limit | Time Window | | ||
| | --- | --- | --- | | ||
| | Game Creation | 20 requests | per hour per IP | | ||
| | Player Creation | 60 requests | per hour per IP | | ||
| | WebSocket Connections | 333 connections | per second per IP | | ||
|
|
||
| ## Configuration | ||
|
|
||
| Rate limits are configurable via environment variables: | ||
|
|
||
| ```bash | ||
| # Game creation limits | ||
| RATE_LIMIT_GAME_CREATION_LIMIT=20 | ||
| RATE_LIMIT_GAME_CREATION_WINDOW=3600 # seconds | ||
|
|
||
| # Player creation limits | ||
| RATE_LIMIT_PLAYER_CREATION_LIMIT=60 | ||
| RATE_LIMIT_PLAYER_CREATION_WINDOW=3600 # seconds | ||
|
|
||
| # Connection limits | ||
| RATE_LIMIT_CONNECTION_LIMIT=333 | ||
| RATE_LIMIT_CONNECTION_WINDOW=1 # seconds | ||
| ``` | ||
|
|
||
| **Note**: Environment variables must be positive integers. Invalid values will log a warning and fall back to defaults. | ||
|
|
||
| ## Implementation Details | ||
|
|
||
| ### Architecture | ||
|
|
||
| - **RateLimiter GenServer** (`lib/copi/rate_limiter.ex`): Core rate limiting logic | ||
| - Tracks requests per IP and action type | ||
| - Implements sliding window algorithm | ||
| - Auto-cleanup of expired entries every 5 minutes | ||
|
|
||
| - **IPHelper Module** (`lib/copi/ip_helper.ex`): IP extraction utilities | ||
| - Provides DRY interface for IP extraction | ||
| - Handles LiveView sockets, Phoenix sockets, and Plug connections | ||
|
|
||
| - **Integration Points**: | ||
| - Game creation: `lib/copi_web/live/game_live/create_game_form.ex` | ||
| - Player creation: `lib/copi_web/live/player_live/form_component.ex` | ||
| - WebSocket connections: `lib/copi_web/channels/user_socket.ex` | ||
|
|
||
| ### Rate Limit Response | ||
|
|
||
| When rate limits are exceeded: | ||
|
|
||
| - **Game/Player Creation**: User receives a flash error message: "Too many [action] attempts. Please try again later." | ||
| - **WebSocket Connections**: Connection is denied (returns `:error`) | ||
| - **Logging**: Rate limit violations are logged for monitoring | ||
|
|
||
| ### IP Address Handling | ||
|
|
||
| - Supports both IPv4 and IPv6 | ||
| - Accepts IP addresses as tuples or strings | ||
| - Normalizes IP formats for consistent tracking | ||
| - **Proxy Support**: Automatically reads `X-Forwarded-For` header to get real client IP when behind reverse proxies | ||
| - Uses leftmost IP from X-Forwarded-For (original client IP) | ||
| - Falls back to `remote_ip` if header is missing or invalid | ||
| - Falls back gracefully when IP is unavailable | ||
|
|
||
| ## Testing | ||
|
|
||
| Comprehensive test coverage in: | ||
| - `test/copi/rate_limiter_test.exs`: Core rate limiter functionality | ||
| - `test/copi_web/channels/user_socket_test.exs`: WebSocket connection rate limiting | ||
|
|
||
| ## Security Considerations | ||
|
|
||
| ### Limitations | ||
|
|
||
| 1. **IP-based tracking**: Can be bypassed by users with multiple IP addresses or using proxies | ||
| 2. **Shared IPs**: Users behind NAT may share rate limits | ||
| 3. **No authentication**: Current implementation doesn't require user authentication | ||
|
|
||
| ### Future Enhancements | ||
|
|
||
| If rate limiting proves insufficient, consider: | ||
| 1. Implementing user authentication and associating limits with user accounts | ||
| 2. Adding browser fingerprinting for better user identification | ||
| 3. Implementing CAPTCHA for repeated violations | ||
| 4. Adding IP allowlisting for trusted networks | ||
| 5. Implementing more sophisticated detection patterns (behavioral analysis) | ||
|
|
||
| ## Monitoring | ||
|
|
||
| The RateLimiter logs configuration on startup and warnings when limits are exceeded. Monitor these logs to: | ||
| - Detect potential attacks | ||
| - Adjust rate limits based on legitimate usage patterns | ||
| - Identify problematic IPs | ||
|
|
||
| ## Threat Model | ||
|
|
||
| This implementation addresses: | ||
|
|
||
| - **CAPEC-212 (Functionality Misuse)**: Prevents abuse of game/player creation | ||
| - **Resource exhaustion**: Prevents overwhelming the system | ||
| - **Service availability**: Ensures legitimate users can access the service | ||
|
|
||
| ## Contact | ||
|
|
||
| For security issues or questions, please refer to the main repository [SECURITY.md](../../SECURITY.md). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| defmodule Copi.IPHelper do | ||
| @moduledoc """ | ||
| Helper module for extracting IP addresses from Phoenix connections and sockets. | ||
|
|
||
| This module provides DRY (Don't Repeat Yourself) functionality for IP extraction | ||
| across different parts of the application (LiveView, controllers, channels). | ||
| """ | ||
|
|
||
| @doc """ | ||
| Extracts the IP address from a Phoenix.Socket. | ||
|
|
||
| Returns the IP as a tuple (e.g., {127, 0, 0, 1}) or a fallback IP if not found. | ||
|
|
||
| ## Examples | ||
| iex> get_ip_from_socket(socket) | ||
| {127, 0, 0, 1} | ||
| """ | ||
| def get_ip_from_socket(%Phoenix.LiveView.Socket{} = socket) do | ||
| case get_connect_info_ip(socket) do | ||
| nil -> {127, 0, 0, 1} # Fallback to localhost (for tests) | ||
| ip when is_tuple(ip) -> ip | ||
| ip -> ip | ||
| end | ||
| end | ||
|
|
||
| def get_ip_from_socket(%Phoenix.Socket{} = socket) do | ||
| case get_transport_ip(socket) do | ||
| nil -> {127, 0, 0, 1} # Fallback to localhost (for tests) | ||
| ip when is_tuple(ip) -> ip | ||
| ip -> ip | ||
| end | ||
| end | ||
|
|
||
| @doc """ | ||
| Extracts the IP address from a Plug.Conn (used in controllers and plugs). | ||
|
|
||
| Supports X-Forwarded-For header when behind reverse proxies. | ||
| Returns the IP as a tuple (e.g., {127, 0, 0, 1}) or a fallback IP if not found. | ||
|
|
||
| ## Examples | ||
| iex> get_ip_from_conn(conn) | ||
| {127, 0, 0, 1} | ||
| """ | ||
| def get_ip_from_conn(%Plug.Conn{} = conn) do | ||
| # Try to get real IP from X-Forwarded-For header first (for proxy scenarios) | ||
| case get_forwarded_ip(conn) do | ||
| nil -> | ||
| # Fall back to remote_ip | ||
| case conn.remote_ip do | ||
| nil -> {127, 0, 0, 1} # Fallback to localhost (for tests) | ||
| ip when is_tuple(ip) -> ip | ||
| ip -> ip | ||
| end | ||
| ip -> ip | ||
| end | ||
| end | ||
|
|
||
| @doc """ | ||
| Converts an IP tuple to a string representation. | ||
|
|
||
| ## Examples | ||
| iex> ip_to_string({127, 0, 0, 1}) | ||
| "127.0.0.1" | ||
|
|
||
| iex> ip_to_string({0, 0, 0, 0, 0, 0, 0, 1}) | ||
| "::1" | ||
| """ | ||
| def ip_to_string(ip) when is_tuple(ip) do | ||
| case :inet.ntoa(ip) do | ||
| {:error, _} -> inspect(ip) | ||
| ip_charlist -> to_string(ip_charlist) | ||
| end | ||
| end | ||
|
|
||
| def ip_to_string(ip) when is_binary(ip), do: ip | ||
| def ip_to_string(ip), do: inspect(ip) | ||
|
|
||
| # Private helpers | ||
|
|
||
| defp get_forwarded_ip(conn) do | ||
| # Get X-Forwarded-For header (rightmost IP is most recent proxy, leftmost is original client) | ||
| case Plug.Conn.get_req_header(conn, "x-forwarded-for") do | ||
| [] -> nil | ||
| [forwarded | _] -> | ||
| # Take the first (leftmost) IP from the comma-separated list | ||
| forwarded | ||
| |> String.split(",") | ||
| |> List.first() | ||
| |> String.trim() | ||
| |> parse_ip_string() | ||
| _ -> nil | ||
| end | ||
| end | ||
|
|
||
| defp parse_ip_string(ip_string) do | ||
| case :inet.parse_address(String.to_charlist(ip_string)) do | ||
| {:ok, ip_tuple} -> ip_tuple | ||
| {:error, _} -> nil | ||
| end | ||
| end | ||
|
|
||
| defp get_connect_info_ip(socket) do | ||
| # Access peer_data from connect_info using nil-safe get_in | ||
| # This prevents crashes when connect_info is nil (e.g., in tests) | ||
| case get_in(socket.private, [:connect_info, :peer_data]) do | ||
| %{address: address} -> address | ||
| _ -> nil | ||
| end | ||
| end | ||
|
Comment on lines
+102
to
+109
|
||
|
|
||
| defp get_transport_ip(socket) do | ||
| if Map.has_key?(socket, :transport_pid) && socket.transport_pid do | ||
| # Try to get from endpoint | ||
| case Process.info(socket.transport_pid, :dictionary) do | ||
| {:dictionary, dict} -> | ||
| Keyword.get(dict, :peer) | ||
| |> case do | ||
| {ip, _port} -> ip | ||
| _ -> nil | ||
| end | ||
|
|
||
| _ -> | ||
| nil | ||
| end | ||
| else | ||
| nil | ||
| end | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR title/description focuses on fixing the voting race condition, but this change also introduces a new global IP-based
RateLimitersupervision child and related endpoint/socket behavior. Please either update the PR description/title to include the rate limiting feature (and its rationale/impact), or split rate limiting into a separate PR to keep scope and review risk manageable.