diff --git a/copi.owasp.org/assets/js/app.js b/copi.owasp.org/assets/js/app.js index 5cbc409a3..9a536da46 100644 --- a/copi.owasp.org/assets/js/app.js +++ b/copi.owasp.org/assets/js/app.js @@ -18,8 +18,8 @@ // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html" // Establish Phoenix Socket and LiveView configuration. -import {Socket} from "phoenix" -import {LiveSocket} from "phoenix_live_view" +import { Socket } from "phoenix" +import { LiveSocket } from "phoenix_live_view" import topbar from "../vendor/topbar" import dragula from "../vendor/dragula" @@ -28,12 +28,29 @@ Hooks.DragDrop = { mounted() { const drake = dragula([document.querySelector('#hand'), document.querySelector('#table')], { invalid: function (el, handle) { - // Don't allow dragging cards off the table - return el.className === 'card-player' || document.querySelector('#round-played'); + // Don't allow dragging cards off the table or placeholder text + const isPlaceholderText = (element) => { + if (!element) return false; + const textContent = element.textContent; + return textContent.includes('Waiting for') || + textContent.includes('You can play') || + textContent.includes('should play'); + }; + + return el.className === 'card-player' || + document.querySelector('#round-played') || + isPlaceholderText(el) || + (el.querySelector && isPlaceholderText(el.querySelector('p'))); }, accepts: function (el, target, source, sibling) { - console.log('accepts called', {target: target?.id, source: source?.id}); - + console.log('accepts called', { target: target?.id, source: source?.id }); + + // CRITICAL FIX: Prevent moving cards from table back to hand + if (target && target.id === 'hand' && source && source.id === 'table') { + console.log('Blocked: Cannot move cards from table back to hand'); + return false; + } + // Only allow dropping on table if (target && target.id === 'table') { // Check if "Your Card" section already has a card (not placeholder) @@ -41,28 +58,28 @@ Hooks.DragDrop = { const nameDiv = zone.querySelector('.name'); return nameDiv && (nameDiv.textContent.includes('Your Card') || nameDiv.textContent.includes('Drop a card')); }); - + if (yourCardSection) { // Check if there's a card already (not the placeholder) const isPlaceholder = yourCardSection.textContent.includes('Drop a card'); - - console.log('Your card section check:', {isPlaceholder}); - + + console.log('Your card section check:', { isPlaceholder }); + if (!isPlaceholder) { console.log('Blocked: card already on table'); return false; } } } - + console.log('Accepting drop'); return true; } }); - + drake.on('drop', (element, target, source, sibling) => { - console.log('Drop event', {target: target?.id}); - + console.log('Drop event', { target: target?.id }); + if (target && target.id === 'table') { fetch('/api/games/' + element.dataset.game + '/players/' + element.dataset.player + '/card', { method: 'PUT', @@ -89,7 +106,7 @@ Hooks.CopyUrl = { const btn = this.el.querySelector("#copy-url-btn"); const urlSpan = this.el.querySelector("#copied-url"); const checkMark = this.el.querySelector("#url-copied"); - + /*urlSpan.textContent = window.location.href;*/ btn.addEventListener("click", () => { const url = urlSpan.value; @@ -103,11 +120,11 @@ Hooks.CopyUrl = { let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: location.host.startsWith("localhost") ? undefined : 2500, // Clients can switch to longpoll and get stuck during development when the server goes up and down - params: {_csrf_token: csrfToken}, hooks: Hooks + params: { _csrf_token: csrfToken }, hooks: Hooks }) // Show progress bar on live navigation and form submits -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) diff --git a/copi.owasp.org/lib/copi_web/components/core_components/table_components.ex b/copi.owasp.org/lib/copi_web/components/core_components/table_components.ex index 6bca734c9..f574daece 100644 --- a/copi.owasp.org/lib/copi_web/components/core_components/table_components.ex +++ b/copi.owasp.org/lib/copi_web/components/core_components/table_components.ex @@ -17,13 +17,13 @@ defmodule CopiWeb.CoreComponents.TableComponents do <%= if @is_current_player do %>
<%= if @first_card_played do %> -

You should play a
<%= @first_card_played.card.category %>
card

+

You should play a
<%= @first_card_played.card.category %>
card

<% else %> -

You can play
any
card

+

You can play
any
card

<% end %>
<% else %> -

Waiting for <%= @player.name %> to play their card

+

Waiting for <%= @player.name %> to play their card

<% end %> <% else %>
game_id, "player_id" => player_id, "dealt_card_id" => dealt_card_id}) do - with {:ok, game} <- Game.find(game_id) do - - player = Enum.find(game.players, fn player -> player.id == player_id end) - dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end) - - if player && dealt_card do - current_round = game.rounds_played + 1 - + case Game.find(game_id) do + {:ok, game} -> + player = Enum.find(game.players, fn player -> player.id == player_id end) + + # CRITICAL SECURITY VALIDATION: Ensure player belongs to the specified game cond do - dealt_card.played_in_round -> - conn |> put_status(:not_acceptable) |> json(%{"error" => "Card already played"}) - Enum.find(player.dealt_cards, fn dealt_card -> dealt_card.played_in_round == current_round end) -> - conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"}) + !player -> + Logger.warning("Security: API play_card attempted - player #{player_id} not found in game #{game_id}, IP: #{get_client_ip(conn)}") + conn |> put_status(:not_found) |> json(%{"error" => "Could not find player"}) + + player.game_id != game_id -> + Logger.warning("Security: API play_card attempted - player #{player_id} does not belong to game #{game_id}, IP: #{get_client_ip(conn)}") + conn |> put_status(:forbidden) |> json(%{"error" => "Player does not belong to this game"}) + true -> - dealt_card = Ecto.Changeset.change dealt_card, played_in_round: current_round + dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end) - case Copi.Repo.update dealt_card do - {:ok, dealt_card} -> - with {:ok, updated_game} <- Game.find(game.id) do - CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game) - else - {:error, _reason} -> - conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"}) - end + cond do + !dealt_card -> + Logger.warning("Security: API play_card attempted - dealt_card #{dealt_card_id} not found for player #{player_id}, IP: #{get_client_ip(conn)}") + conn |> put_status(:not_found) |> json(%{"error" => "Could not find dealt card"}) + + dealt_card.played_in_round -> + conn |> put_status(:not_acceptable) |> json(%{"error" => "Card already played"}) + + Enum.find(player.dealt_cards, fn dealt_card -> dealt_card.played_in_round == game.rounds_played + 1 end) -> + conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"}) + + true -> + dealt_card = Ecto.Changeset.change dealt_card, played_in_round: game.rounds_played + 1 + + case Copi.Repo.update dealt_card do + {:ok, dealt_card} -> + with {:ok, updated_game} <- Game.find(game.id) do + CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game) + else + {:error, _reason} -> + conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"}) + end - conn |> json(%{"id" => dealt_card.id}) - {:error, _changeset} -> - conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"}) + conn |> json(%{"id" => dealt_card.id}) + {:error, _changeset} -> + conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"}) + end end end - else - conn |> put_status(:not_found) |> json(%{"error" => "Could not find player and dealt card"}) - end - else - {:error, _reason} -> conn |> put_status(:not_found) |> json(%{"error" => "Could not find game"}) + + {:error, _reason} -> + Logger.warning("Security: API play_card attempted - game #{game_id} not found, IP: #{get_client_ip(conn)}") + conn |> put_status(:not_found) |> json(%{"error" => "Could not find game"}) + end + end + + defp get_client_ip(conn) do + # Get IP from various headers, fallback to remote_ip + case get_req_header(conn, "x-forwarded-for") do + [ip | _] -> ip + [] -> + case get_req_header(conn, "x-real-ip") do + [ip | _] -> ip + [] -> to_string(:inet_parse.ntoa(conn.remote_ip)) + end end end