Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions copi.owasp.org/SECURITY.md
Original file line number Diff line number Diff line change
@@ -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 [email protected] with details.
Do not create public GitHub issues for security problems.
32 changes: 32 additions & 0 deletions copi.owasp.org/config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions copi.owasp.org/lib/copi/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
229 changes: 229 additions & 0 deletions copi.owasp.org/lib/copi/rate_limiter.ex
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using put_in/3 with a list of keys on a potentially non-existent nested path will raise an error if the IP address key doesn't exist in the map. Use Kernel.put_in/3 or ensure the path exists first, or consider using Map.put/3 with Map.get/3 for safer nested map updates.

Copilot uses AI. Check for mistakes.
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
)
Comment on lines +173 to +177
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the issue in check_and_record, using put_in/3 with a path that may not exist will raise an error. This occurs when recording an action for a new IP address that hasn't been seen before.

Copilot uses AI. Check for mistakes.

{: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
4 changes: 2 additions & 2 deletions copi.owasp.org/lib/copi_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
37 changes: 37 additions & 0 deletions copi.owasp.org/lib/copi_web/helpers/ip_helper.ex
Original file line number Diff line number Diff line change
@@ -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
Loading