Skip to content

Commit de23b62

Browse files
feat: Implement atomic operations and rate limiting with merge conflict resolution
- Add atomic card play operations to prevent race conditions - Implement rate limiting for API endpoints - Add comprehensive test coverage for rate limiting - Resolve merge conflicts with upstream improvements - Enhance error handling and validation in api_controller.ex
1 parent ac933d7 commit de23b62

6 files changed

Lines changed: 572 additions & 38 deletions

File tree

copi.owasp.org/lib/copi/rate_limiter.ex

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ defmodule Copi.RateLimiter do
66
- Game creation
77
- Player creation
88
- WebSocket connections
9+
- API actions
910
1011
Rate limits are configured via environment variables and automatically clean up expired entries.
1112
"""
@@ -30,21 +31,22 @@ defmodule Copi.RateLimiter do
3031
3132
## Parameters
3233
- ip: IP address as a tuple (e.g., {127, 0, 0, 1}) or string
33-
- action: atom representing the action (:game_creation, :player_creation, :connection)
34+
- action: atom representing the action (:game_creation, :player_creation, :connection, :api_action)
3435
3536
## Examples
3637
iex> Copi.RateLimiter.check_rate("127.0.0.1", :game_creation)
3738
{:ok, 9}
3839
39-
iex> Copi.RateLimiter.check_rate({127, 0, 0, 1}, :connection)
40-
{:error, :rate_limit_exceeded}
40+
iex> Copi.RateLimiter.check_rate({127, 0, 0, 1}, :api_action)
41+
{:ok, 9}
4142
"""
42-
def check_rate(ip, action) when action in [:game_creation, :player_creation, :connection] do
43+
def check_rate(ip, action) when action in [:game_creation, :player_creation, :connection, :api_action] do
4344
normalized_ip = normalize_ip(ip)
4445

4546
# In production, don't rate limit localhost to prevent DoS'ing ourselves
47+
# Only bypass rate limiting if the actual connection IP is loopback, not X-Forwarded-For
4648
Logger.debug("check_rate: Checking rate limit for IP #{inspect(normalized_ip)} on action #{action}")
47-
if Application.get_env(:copi, :env) == :prod and normalized_ip == {127, 0, 0, 1} do
49+
if Application.get_env(:copi, :env) == :prod and normalized_ip == {127, 0, 0, 1} and ip == {127, 0, 0, 1} do
4850
{:ok, :unlimited}
4951
else
5052
GenServer.call(__MODULE__, {:check_rate, normalized_ip, action})
@@ -58,6 +60,13 @@ defmodule Copi.RateLimiter do
5860
GenServer.cast(__MODULE__, {:clear_ip, normalize_ip(ip)})
5961
end
6062

63+
@doc """
64+
Synchronously clears all rate limit data for a specific IP address (useful for testing).
65+
"""
66+
def clear_ip_sync(ip) do
67+
GenServer.call(__MODULE__, {:clear_ip, normalize_ip(ip)})
68+
end
69+
6170
@doc """
6271
Gets the current configuration for rate limits.
6372
"""
@@ -76,12 +85,14 @@ defmodule Copi.RateLimiter do
7685
limits: %{
7786
game_creation: get_env_config(:game_creation_limit, 20),
7887
player_creation: get_env_config(:player_creation_limit, 60),
79-
connection: get_env_config(:connection_limit, 133)
88+
connection: get_env_config(:connection_limit, 133),
89+
api_action: get_env_config(:api_action_limit, 133)
8090
},
8191
windows: %{
8292
game_creation: get_env_config(:game_creation_window, 3600),
8393
player_creation: get_env_config(:player_creation_window, 3600),
84-
connection: get_env_config(:connection_window, 1)
94+
connection: get_env_config(:connection_window, 1),
95+
api_action: get_env_config(:api_action_window, 133)
8596
},
8697
requests: %{}
8798
}
@@ -127,6 +138,17 @@ defmodule Copi.RateLimiter do
127138
{:reply, config, state}
128139
end
129140

141+
@impl true
142+
def handle_call({:clear_ip, ip}, _from, state) do
143+
new_requests =
144+
state.requests
145+
|> Enum.reject(fn {{request_ip, _action}, _timestamps} -> request_ip == ip end)
146+
|> Enum.into(%{})
147+
148+
new_state = %{state | requests: new_requests}
149+
{:reply, :ok, new_state}
150+
end
151+
130152
@impl true
131153
def handle_cast({:clear_ip, ip}, state) do
132154
new_requests =

copi.owasp.org/lib/copi_web/controllers/api_controller.ex

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,39 @@
11
defmodule CopiWeb.ApiController do
22
use CopiWeb, :controller
33
alias Copi.Cornucopia.Game
4+
alias Copi.Repo
5+
import Ecto.Query
6+
47
def play_card(conn, %{"game_id" => game_id, "player_id" => player_id, "dealt_card_id" => dealt_card_id}) do
58
with {:ok, game} <- Game.find(game_id) do
69
player = Enum.find(game.players, fn player -> player.id == player_id end)
7-
if player do
8-
dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end)
9-
if dealt_card do
10-
current_round = game.rounds_played + 1
11-
cond do
12-
dealt_card.played_in_round ->
13-
conn |> put_status(:not_acceptable) |> json(%{"error" => "Card already played"})
14-
Enum.find(player.dealt_cards, fn dealt_card -> dealt_card.played_in_round == current_round end) ->
15-
conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"})
16-
true ->
17-
dealt_card = Ecto.Changeset.change dealt_card, played_in_round: current_round
18-
case Copi.Repo.update dealt_card do
19-
{:ok, dealt_card} ->
20-
with {:ok, updated_game} <- Game.find(game.id) do
21-
CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game)
22-
else
23-
{:error, _reason} ->
24-
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"})
25-
end
26-
conn |> json(%{"id" => dealt_card.id})
27-
{:error, _changeset} ->
28-
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"})
29-
end
30-
end
31-
else
32-
conn |> put_status(:not_found) |> json(%{"error" => "Could not find player and dealt card"})
10+
dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end)
11+
12+
if player && dealt_card do
13+
current_round = game.rounds_played + 1
14+
15+
cond do
16+
dealt_card.played_in_round ->
17+
conn |> put_status(:not_acceptable) |> json(%{"error" => "Card already played"})
18+
Enum.find(player.dealt_cards, fn dealt_card -> dealt_card.played_in_round == current_round end) ->
19+
conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"})
20+
true ->
21+
# Atomic update to prevent race conditions
22+
case play_card_atomically(dealt_card, current_round) do
23+
{:ok, updated_card} ->
24+
with {:ok, updated_game} <- Game.find(game.id) do
25+
CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game)
26+
else
27+
{:error, _reason} ->
28+
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"})
29+
end
30+
31+
conn |> json(%{"id" => updated_card.id})
32+
{:error, :already_played} ->
33+
conn |> put_status(:conflict) |> json(%{"error" => "Card was already played by another request"})
34+
{:error, _changeset} ->
35+
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"})
36+
end
3337
end
3438
else
3539
conn |> put_status(:not_found) |> json(%{"error" => "Player not found in this game"})
@@ -38,6 +42,17 @@ defmodule CopiWeb.ApiController do
3842
{:error, _reason} -> conn |> put_status(:not_found) |> json(%{"error" => "Could not find game"})
3943
end
4044
end
45+
46+
# Atomic card play operation to prevent race conditions
47+
defp play_card_atomically(dealt_card, current_round) do
48+
# Use Ecto's atomic operations to prevent race conditions
49+
from(dc in Copi.Cornucopia.DealtCard, where: dc.id == ^dealt_card.id and is_nil(dc.played_in_round))
50+
|> Repo.update_all([set: [played_in_round: current_round]], returning: true)
51+
|> case do
52+
{1, [updated_card]} -> {:ok, updated_card}
53+
{0, []} -> {:error, :already_played}
54+
end
55+
end
4156
def topic(game_id) do
4257
"game:#{game_id}"
4358
end

copi.owasp.org/lib/copi_web/plugs/rate_limiter_plug.ex

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,38 @@ defmodule CopiWeb.Plugs.RateLimiterPlug do
99
def call(conn, _opts) do
1010
case IPHelper.get_ip_source(conn) do
1111
{:forwarded, ip} ->
12+
# Determine action type based on request path and method
13+
action = determine_action(conn)
14+
1215
# Only enforce connection rate limits when we have a forwarded
1316
# client IP (left-most value from X-Forwarded-For). This avoids
1417
# rate-limiting on internal/transport addresses injected by the
1518
# reverse proxy or adapter.
16-
case RateLimiter.check_rate(ip, :connection) do
19+
case RateLimiter.check_rate(ip, action) do
1720
{:ok, _remaining} ->
1821
# Persist the chosen client IP into the session so LiveView
1922
# receives it on websocket connect (connect_info.session).
20-
put_session(conn, "client_ip", IPHelper.ip_to_string(ip))
23+
# Only write to session if it's available (stateless API requests won't have sessions)
24+
if Map.has_key?(conn.private, :plug_session) do
25+
put_session(conn, "client_ip", IPHelper.ip_to_string(ip))
26+
else
27+
conn
28+
end
2129

2230
{:error, :rate_limit_exceeded} ->
23-
Logger.warning("HTTP connection rate limit exceeded for IP: #{inspect(ip)}")
31+
# Anonymize IP for logging to avoid PII in logs
32+
anonymized_ip = ip |> IPHelper.ip_to_string() |> :crypto.hash(:sha256) |> Base.encode16()
33+
Logger.warning("HTTP #{action} rate limit exceeded for IP: #{anonymized_ip}")
2434
conn
2535
|> put_resp_content_type("text/plain")
26-
|> send_resp(429, "Too many connections, try again later.")
36+
|> send_resp(429, "Too many requests, try again later.")
2737
|> halt()
2838
end
2939

3040
{:remote, _ip} ->
3141
# We only have a transport (remote) IP; skip connection rate limiting
3242
# to avoid false positives caused by proxies or adapter internals.
33-
Logger.debug("Skipping connection rate limiting: only transport IP available")
43+
Logger.debug("Skipping rate limiting: only transport IP available")
3444
conn
3545

3646
{:none, _} ->
@@ -39,4 +49,18 @@ defmodule CopiWeb.Plugs.RateLimiterPlug do
3949
conn
4050
end
4151
end
52+
53+
# Determine the action type for rate limiting based on the request
54+
defp determine_action(conn) do
55+
case conn.method do
56+
"PUT" ->
57+
if String.ends_with?(conn.request_path, "/card") do
58+
:api_action
59+
else
60+
:connection
61+
end
62+
_ ->
63+
:connection
64+
end
65+
end
4266
end

copi.owasp.org/lib/copi_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ defmodule CopiWeb.Router do
1313

1414
pipeline :api do
1515
plug :accepts, ["json"]
16+
plug CopiWeb.Plugs.RateLimiterPlug
1617
end
1718

1819
scope "/", CopiWeb do

0 commit comments

Comments
 (0)