Skip to content

Commit 5e6e5fb

Browse files
Fix: Make placeholder cards non-draggable and prevent table-to-hand card movement
- Updated Dragula invalid function to prevent dragging placeholder text elements - Added checks for 'Waiting for', 'You can play', and 'should play' text patterns - Enhanced accepts function to block table-to-hand card movement - Made placeholder text elements non-draggable with CSS properties
1 parent 11d2748 commit 5e6e5fb

File tree

3 files changed

+95
-50
lines changed

3 files changed

+95
-50
lines changed

copi.owasp.org/assets/js/app.js

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
1919
import "phoenix_html"
2020
// Establish Phoenix Socket and LiveView configuration.
21-
import {Socket} from "phoenix"
22-
import {LiveSocket} from "phoenix_live_view"
21+
import { Socket } from "phoenix"
22+
import { LiveSocket } from "phoenix_live_view"
2323
import topbar from "../vendor/topbar"
2424
import dragula from "../vendor/dragula"
2525

@@ -28,41 +28,58 @@ Hooks.DragDrop = {
2828
mounted() {
2929
const drake = dragula([document.querySelector('#hand'), document.querySelector('#table')], {
3030
invalid: function (el, handle) {
31-
// Don't allow dragging cards off the table
32-
return el.className === 'card-player' || document.querySelector('#round-played');
31+
// Don't allow dragging cards off the table or placeholder text
32+
const isPlaceholderText = (element) => {
33+
if (!element) return false;
34+
const textContent = element.textContent;
35+
return textContent.includes('Waiting for') ||
36+
textContent.includes('You can play') ||
37+
textContent.includes('should play');
38+
};
39+
40+
return el.className === 'card-player' ||
41+
document.querySelector('#round-played') ||
42+
isPlaceholderText(el) ||
43+
(el.querySelector && isPlaceholderText(el.querySelector('p')));
3344
},
3445
accepts: function (el, target, source, sibling) {
35-
console.log('accepts called', {target: target?.id, source: source?.id});
36-
46+
console.log('accepts called', { target: target?.id, source: source?.id });
47+
48+
// CRITICAL FIX: Prevent moving cards from table back to hand
49+
if (target && target.id === 'hand' && source && source.id === 'table') {
50+
console.log('Blocked: Cannot move cards from table back to hand');
51+
return false;
52+
}
53+
3754
// Only allow dropping on table
3855
if (target && target.id === 'table') {
3956
// Check if "Your Card" section already has a card (not placeholder)
4057
const yourCardSection = Array.from(document.querySelectorAll('#table .card-player')).find(zone => {
4158
const nameDiv = zone.querySelector('.name');
4259
return nameDiv && (nameDiv.textContent.includes('Your Card') || nameDiv.textContent.includes('Drop a card'));
4360
});
44-
61+
4562
if (yourCardSection) {
4663
// Check if there's a card already (not the placeholder)
4764
const isPlaceholder = yourCardSection.textContent.includes('Drop a card');
48-
49-
console.log('Your card section check:', {isPlaceholder});
50-
65+
66+
console.log('Your card section check:', { isPlaceholder });
67+
5168
if (!isPlaceholder) {
5269
console.log('Blocked: card already on table');
5370
return false;
5471
}
5572
}
5673
}
57-
74+
5875
console.log('Accepting drop');
5976
return true;
6077
}
6178
});
62-
79+
6380
drake.on('drop', (element, target, source, sibling) => {
64-
console.log('Drop event', {target: target?.id});
65-
81+
console.log('Drop event', { target: target?.id });
82+
6683
if (target && target.id === 'table') {
6784
fetch('/api/games/' + element.dataset.game + '/players/' + element.dataset.player + '/card', {
6885
method: 'PUT',
@@ -89,7 +106,7 @@ Hooks.CopyUrl = {
89106
const btn = this.el.querySelector("#copy-url-btn");
90107
const urlSpan = this.el.querySelector("#copied-url");
91108
const checkMark = this.el.querySelector("#url-copied");
92-
109+
93110
/*urlSpan.textContent = window.location.href;*/
94111
btn.addEventListener("click", () => {
95112
const url = urlSpan.value;
@@ -103,11 +120,11 @@ Hooks.CopyUrl = {
103120
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
104121
let liveSocket = new LiveSocket("/live", Socket, {
105122
longPollFallbackMs: location.host.startsWith("localhost") ? undefined : 2500, // Clients can switch to longpoll and get stuck during development when the server goes up and down
106-
params: {_csrf_token: csrfToken}, hooks: Hooks
123+
params: { _csrf_token: csrfToken }, hooks: Hooks
107124
})
108125

109126
// Show progress bar on live navigation and form submits
110-
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
127+
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
111128
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
112129
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
113130

copi.owasp.org/lib/copi_web/components/core_components/table_components.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ defmodule CopiWeb.CoreComponents.TableComponents do
1717
<%= if @is_current_player do %>
1818
<div class="h-5/6 w-5/6 shadow-md bg-zinc-100 border-2 border-zinc-200 rounded-lg animate-pulse flex justify-center items-center">
1919
<%= if @first_card_played do %>
20-
<p class="play-instruction text-center text-zinc-600">You <em>should</em> play a <br /><%= @first_card_played.card.category %> <br /> card</p>
20+
<p class="play-instruction text-center text-zinc-600" draggable="false" style="user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; cursor: default;">You <em>should</em> play a <br /><%= @first_card_played.card.category %> <br /> card</p>
2121
<% else %>
22-
<p class="text-center text-zinc-600">You can play<br />any <br /> card</p>
22+
<p class="text-center text-zinc-600" draggable="false" style="user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; cursor: default;">You can play<br />any <br /> card</p>
2323
<% end %>
2424
</div>
2525
<% else %>
26-
<p class="text-center">Waiting for <%= @player.name %> to play their card</p>
26+
<p class="text-center" draggable="false" style="user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; cursor: default;">Waiting for <%= @player.name %> to play their card</p>
2727
<% end %>
2828
<% else %>
2929
<div

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

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,72 @@
11
defmodule CopiWeb.ApiController do
22
use CopiWeb, :controller
3+
require Logger
4+
35
alias Copi.Cornucopia.Game
46

5-
67
def play_card(conn, %{"game_id" => game_id, "player_id" => player_id, "dealt_card_id" => dealt_card_id}) do
7-
with {:ok, game} <- Game.find(game_id) do
8-
9-
player = Enum.find(game.players, fn player -> player.id == player_id end)
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-
8+
case Game.find(game_id) do
9+
{:ok, game} ->
10+
player = Enum.find(game.players, fn player -> player.id == player_id end)
11+
12+
# CRITICAL SECURITY VALIDATION: Ensure player belongs to the specified game
1513
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"})
14+
!player ->
15+
Logger.warning("Security: API play_card attempted - player #{player_id} not found in game #{game_id}, IP: #{get_client_ip(conn)}")
16+
conn |> put_status(:not_found) |> json(%{"error" => "Could not find player"})
17+
18+
player.game_id != game_id ->
19+
Logger.warning("Security: API play_card attempted - player #{player_id} does not belong to game #{game_id}, IP: #{get_client_ip(conn)}")
20+
conn |> put_status(:forbidden) |> json(%{"error" => "Player does not belong to this game"})
21+
2022
true ->
21-
dealt_card = Ecto.Changeset.change dealt_card, played_in_round: current_round
23+
dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end)
2224

23-
case Copi.Repo.update dealt_card do
24-
{:ok, dealt_card} ->
25-
with {:ok, updated_game} <- Game.find(game.id) do
26-
CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game)
27-
else
28-
{:error, _reason} ->
29-
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"})
30-
end
25+
cond do
26+
!dealt_card ->
27+
Logger.warning("Security: API play_card attempted - dealt_card #{dealt_card_id} not found for player #{player_id}, IP: #{get_client_ip(conn)}")
28+
conn |> put_status(:not_found) |> json(%{"error" => "Could not find dealt card"})
29+
30+
dealt_card.played_in_round ->
31+
conn |> put_status(:not_acceptable) |> json(%{"error" => "Card already played"})
32+
33+
Enum.find(player.dealt_cards, fn dealt_card -> dealt_card.played_in_round == game.rounds_played + 1 end) ->
34+
conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"})
35+
36+
true ->
37+
dealt_card = Ecto.Changeset.change dealt_card, played_in_round: game.rounds_played + 1
38+
39+
case Copi.Repo.update dealt_card do
40+
{:ok, dealt_card} ->
41+
with {:ok, updated_game} <- Game.find(game.id) do
42+
CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game)
43+
else
44+
{:error, _reason} ->
45+
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"})
46+
end
3147

32-
conn |> json(%{"id" => dealt_card.id})
33-
{:error, _changeset} ->
34-
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"})
48+
conn |> json(%{"id" => dealt_card.id})
49+
{:error, _changeset} ->
50+
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"})
51+
end
3552
end
3653
end
37-
else
38-
conn |> put_status(:not_found) |> json(%{"error" => "Could not find player and dealt card"})
39-
end
40-
else
41-
{:error, _reason} -> conn |> put_status(:not_found) |> json(%{"error" => "Could not find game"})
54+
55+
{:error, _reason} ->
56+
Logger.warning("Security: API play_card attempted - game #{game_id} not found, IP: #{get_client_ip(conn)}")
57+
conn |> put_status(:not_found) |> json(%{"error" => "Could not find game"})
58+
end
59+
end
60+
61+
defp get_client_ip(conn) do
62+
# Get IP from various headers, fallback to remote_ip
63+
case get_req_header(conn, "x-forwarded-for") do
64+
[ip | _] -> ip
65+
[] ->
66+
case get_req_header(conn, "x-real-ip") do
67+
[ip | _] -> ip
68+
[] -> to_string(:inet_parse.ntoa(conn.remote_ip))
69+
end
4270
end
4371
end
4472

0 commit comments

Comments
 (0)