diff --git a/ThreatDragonModels/copi.json b/ThreatDragonModels/copi.json index 2d36daf7e..3e987207d 100644 --- a/ThreatDragonModels/copi.json +++ b/ThreatDragonModels/copi.json @@ -358,13 +358,13 @@ }, { "id": "477ee678-6f8c-4ce8-a9f7-83ecc5a34f71", - "title": "ATJ: Be aware of data exposure risk! Copi does not support authentication", + "title": "ATJ: Anyone with a game link can watch the game", "status": "Open", "severity": "Low", "type": null, "eopGameId": "cornucopia", - "description": "What can go wrong?\n\nMark can access resources or services because there is no authentication requirement, or it was mistakenly assumed authentication would be undertaken by some other system or performed in some previous action.\nWe have not implemented Authentication when using Copi, instead we use a secure randomized string to prevent accidental data exposure. Still, an attacker may get hold of such a url by spoofing Copi or other Colleagues in your organization by leveraging various social engineering techniques like establishing a rogue location: https://capec.mitre.org/data/definitions/616.html.", - "mitigation": "What can we do about it?\n\nAs a security measure, you can choose to run copi on a private cluster\nYou should avoid using your own name or the name of a company or project when creating players and games at copi.owasp.org. And remind others not to do so as well. Instead use a pseudonyme and a fake threat model name.", + "description": "What can go wrong?\n\nAnyone with a game link can watch that game. This is intended. Someone may obtain a game link, a short-lived player capability, or a player session cookie through social engineering, logs, a compromised browser, or a compromised network.\n\nWatching a game does not grant player access. Player access requires a signed capability that expires after 5 minutes. If someone steals that capability before it is used, they may exchange it and become that player. A proxy or monitoring service that records POST request bodies may capture the capability.\n\nBy default, Copi remembers used capabilities in the memory of one application node. That record is lost when the node restarts. Clustered nodes normally share the check through one global registry. If the nodes lose contact, each node uses its local record and the same capability may be accepted by more than one node. Synchronizing later cannot undo an exchange that already succeeded.\n\nOptional PostgreSQL storage avoids these replay gaps, but sessions then depend on the database being available. Session records are encrypted, but anyone who obtains both the database contents and COPI_ENCRYPTION_KEY can read them.\n\nA stolen session cookie can be used to act as every player stored in that session until it expires. An individual session cannot be revoked in default cookie mode. PostgreSQL mode allows revocation by deleting the server-side session record.\n\nVoting and card play still have race conditions. Concurrent requests may create duplicate votes or play more than one card in a round because the database does not enforce those limits.", + "mitigation": "What are we going to do about it?\n\nUse HTTPS and do not record capability exchange request bodies in proxies, monitoring systems, or access logs. Protect SECRET_KEY_BASE and COPI_ENCRYPTION_KEY, and rotate the affected key if it is exposed.\n\nUse PostgreSQL session storage when a capability must be accepted only once across several application nodes. Restrict access to the session tables and use verified TLS for the database connection. Without PostgreSQL mode, accept that a restart or cluster partition can allow reuse during the capability's 5-minute lifetime.\n\nDo not use real names or the name of a company or project for games and players. Use pseudonyms and made-up threat model names. Host Copi on a private network when public viewing by link is not acceptable.\n\nVoting integrity remains tracked in issue 2568.", "modelType": "EOP", "new": false, "number": 9, diff --git a/copi.owasp.org/README.md b/copi.owasp.org/README.md index 08c12c52d..377ecb569 100644 --- a/copi.owasp.org/README.md +++ b/copi.owasp.org/README.md @@ -96,6 +96,24 @@ Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). +## Player session and replay storage + +Copi uses encrypted cookie sessions and single-node, in-memory capability replay protection by default. This mode does not store player sessions or capability digests in PostgreSQL. + +When `DNS_CLUSTER_QUERY` is set, replay registries coordinate through a globally registered Erlang process. A node falls back to its local registry if the global process cannot be reached and synchronizes its active digests when the connection returns. A capability can still be accepted on both sides of a network partition before that synchronization happens. + +PostgreSQL-backed player sessions and replay protection are optional: + +```bash +POSTGRES_SESSION_STORE_ENABLED=true +``` + +When enabled, the browser cookie contains an opaque session ID protected by the normal Phoenix cookie encryption. The player session written to the `copi_sessions` table is separately encrypted with `COPI_ENCRYPTION_KEY`. Consumed capability digests are inserted atomically into `player_capability_consumptions`, so nodes using the same database share replay protection. PostgreSQL mode takes precedence over the in-memory and clustered replay registries. + +Run the database migrations before enabling this option. Configure every application node with the same setting, `SECRET_KEY_BASE`, and `COPI_ENCRYPTION_KEY`. `COPI_ENCRYPTION_KEY` is used for session encryption only when state is written to or read from PostgreSQL; browser cookies continue to use Phoenix's `SECRET_KEY_BASE`. Protect database connections with verified TLS when the database is not on a trusted local network. If PostgreSQL is unavailable, session loading and capability exchange fail rather than falling back to a weaker store. + +Changing the storage mode invalidates existing browser sessions. Capability exchange tokens remain valid for 5 minutes and player sessions remain valid for up to 7 days in every mode. + ## More about Phoenix * Official website: [https://www.phoenixframework.org/](https://www.phoenixframework.org/) diff --git a/copi.owasp.org/SECURITY.md b/copi.owasp.org/SECURITY.md index 9d4941bab..cbb7645ee 100644 --- a/copi.owasp.org/SECURITY.md +++ b/copi.owasp.org/SECURITY.md @@ -124,6 +124,30 @@ The RateLimiter logs configuration on startup and warnings when limits are excee - Adjust rate limits based on legitimate usage patterns - Identify problematic IPs +## Player Capabilities + +Anyone with a game link can watch that game. Watching does not require a player session. + +Player actions have a separate check. When a player is created, Copi signs a capability that contains the game ID, the player ID, and the `player` purpose. The capability expires after 5 minutes. The browser sends it in the body of a CSRF-protected POST request. The endpoint adds the player to the encrypted session cookie and returns the normal player URL without the capability. + +Each capability is intended for one exchange. Copi stores only a SHA-256 digest for 5 minutes. The check and update are one atomic operation in each replay store. The bearer value is not stored in a replay registry. + +By default, replay protection uses memory on one application node. When `DNS_CLUSTER_QUERY` enables Erlang clustering, each node first uses one globally registered registry. If it cannot reach that registry, it uses its local registry and synchronizes active digests after the connection returns. This keeps capability exchange available during a cluster outage, but the same capability may be accepted independently on both sides of a partition before synchronization. Synchronization cannot undo an exchange that already succeeded. In-memory entries are also lost when a node restarts. + +PostgreSQL storage can be enabled with `POSTGRES_SESSION_STORE_ENABLED=true`. In that mode, a unique database insert provides replay protection across all nodes that use the same database. PostgreSQL replay protection takes precedence over the local and clustered registries. If the database is unavailable, the exchange fails instead of falling back to memory. + +The player session lasts for up to 7 days. By default, it is stored in the signed and encrypted Phoenix session cookie. With PostgreSQL storage enabled, the cookie contains an opaque session ID protected by Phoenix cookie encryption, while session data written to the `copi_sessions` table is encrypted with `COPI_ENCRYPTION_KEY` using AES-256-GCM. `COPI_ENCRYPTION_KEY` is not used for browser cookie encryption or capability signing. One session can contain several player capabilities for the same game and for different games. Adding a player does not remove existing players. The cookie is HTTP-only, uses `SameSite=Lax`, and is marked secure in production. + +The game ID and player ID in the URL select the active player. Copi accepts the action only when that exact game and player pair is present in the cookie. A player ID from another stored game cannot be combined with the current game ID. Copi also checks that the card belongs to the selected player. Card play uses CSRF protection. + +A game link and a player capability are not interchangeable. A game link allows someone to watch a game. It does not allow that person to play a card as another player. + +The player capability is a bearer secret until it is exchanged or expires. Sending it in a POST body keeps it out of the address bar, browser history, referrer headers, and normal URL logs. The exchange response uses `Cache-Control: no-store`. Use HTTPS and make sure proxies and application monitoring do not log request bodies containing capabilities. + +Cluster mode improves replay protection while nodes are connected, but it is not a consensus or durable store. A network partition, global registry timeout, node restart, or cluster restart can allow reuse during the 5-minute capability lifetime. PostgreSQL mode avoids those replay gaps when every node uses the same available database, but it adds session availability and confidentiality dependence on PostgreSQL. Use verified database TLS and restrict access to the session tables. + +The encrypted session cookie is also a bearer credential. In default cookie mode, a stolen copy can be used until it expires unless the signing key is rotated. In PostgreSQL mode, a copied cookie refers to the same server-side row and can be invalidated by deleting that row, but it remains usable until expiry if no revocation occurs. The cookie is not tied to an IP address because client addresses change and may be shared or supplied through an untrusted proxy header. A compromised browser or script running in the Copi origin may still perform actions using an HTTP-only cookie even though it cannot read the cookie value. + ## Encryption Key Setup Game and player names are encrypted at the application level using AES-256-GCM. @@ -159,26 +183,35 @@ Please also read [SECURITY.md](SECURITY.md) to ensure you have taken the appropr Here is a short summary of what you need to be aware of: -### ATJ: Mark can access resources or services because there is no authentication requirement, or it was mistakenly assumed that authentication would be undertaken by some other system or performed in some previous action. +### ATJ: Anyone with a game link can watch the game #### What can go wrong? -Be aware of data exposure risk! Copi does not support authentication. -We have not implemented Authentication when using Copi, instead we use a secure randomized string to prevent accidental data exposure. Still, an attacker may get hold of such a url by spoofing Copi or other Colleagues in your organization by leveraging various social engineering techniques like establishing a rogue location: [https://capec.mitre.org/data/definitions/616.html](https://capec.mitre.org/data/definitions/616.html). +Copi does not use user accounts. Anyone with a game link can watch that game. This is intended. + +Someone may obtain a game link, a short-lived player capability, or a player session cookie through social engineering, logs, a compromised browser, or a compromised network. See [CAPEC-616](https://capec.mitre.org/data/definitions/616.html) and [CAPEC-569](https://capec.mitre.org/data/definitions/569.html). + +Watching a game does not grant player access. Player access requires a signed capability that expires after 5 minutes. If someone steals that capability before it is used, they may exchange it and become that player. The capability is sent in a POST body, so a proxy or monitoring service that records request bodies may capture it. -An attacker could use various tools for capturing logs or http requests which may lead to information disclosure if your participants' network has been comporised: [https://capec.mitre.org/data/definitions/569.html](https://capec.mitre.org/data/definitions/569.html). +By default, Copi remembers used capabilities in the memory of one application node. That record is lost when the node restarts. In a cluster, nodes normally share this check through one global registry. If the nodes lose contact, each node uses its own local record. The same capability may then be accepted by more than one node. Synchronizing the records later cannot undo an exchange that has already succeeded. -Do you think this is strange? Indeed, in this day and age, it is, but if we were to implement authentication, we would also have to process more personal information, which would open us up to more threats. We could indeed mitigate those threats, but we would rather remain privacy-friendly and process as little personal information as possible. +An optional PostgreSQL mode avoids these replay gaps by keeping the session and replay records in one database. This makes player sessions depend on the database being available. The session records are encrypted, but anyone who obtains both the database contents and `COPI_ENCRYPTION_KEY` can read them. -Game integrity is still enforceable only by client behavior in a few important paths. During the game, the app only checks that a voted card belongs to the same game before inserting a vote, so a crafted client can self-vote because there is no server-side owner check. The app separately creates the votes table without a uniqueness constraint, and the dealt_cards table stores played_in_round without any DB-level rule that limits a player to one card per round. A malicious or racing client can therefore duplicate votes or play multiple cards in the same round. +The player session cookie is a bearer credential. Anyone who steals a valid copy can act as every player stored in that session until it expires. In the default cookie mode, an individual stolen session cannot be revoked. In PostgreSQL mode, deleting the server-side session record revokes it. + +Voting and card play still have race conditions. The votes table has no uniqueness constraint, so two requests made at nearly the same time may create duplicate votes. The database also has no rule that limits a player to one played card per round, so concurrent requests may play more than one card. #### What are we going to do about it? -We are not working towards implementing authentication in Copi. Instead, we are utilizing magic links. Arguable this is not authentication, but it's worth noting that your threat model is not stored on copi.owasp.org, just your game and the cards you voted on. For a threat actor to be able to piece together this information and use it against you, given that he gets hold of the magic link, you would have to use your full name and add the URL to your project in the game name field when creating the game. We are working towards informing users that they should under no circumstances do this kind of thing, but even in the case that you still do. The cards themselves are too generic and don't contain the sensitive discussions that you had during your game. -As a security measure, you can choose to run Copi on a private cluster -You should avoid using your own name or the name of a company or project when creating players and games at copi.owasp.org. And remind others not to do so as well. Instead, use a pseudonym and a fake threat model name. +Use HTTPS. Do not record capability exchange request bodies in proxies, application monitoring, or access logs. Protect `SECRET_KEY_BASE` and `COPI_ENCRYPTION_KEY`, and rotate the affected key if it is exposed. + +Use PostgreSQL session storage when a capability must be accepted only once across several application nodes. Restrict access to the session tables and use verified TLS for the database connection. Without PostgreSQL mode, accept that a restart or cluster partition can allow a capability to be reused during its 5-minute lifetime. + +Copi stores game and card choices, but not the discussion held by the players. Do not use a real person, company, or project name for a game or player. Use a pseudonym and a made-up threat model name. + +Run Copi on a private network if viewing by game link is not suitable for your use case. -There is a GitHub issue to resolve the voting integrity vulnerability (see: https://github.com/OWASP/cornucopia/issues/2568). The damage is limited by the fact that most players during a game know each other and by having the url to the game being a random magic link. +Voting integrity is tracked in [issue 2568](https://github.com/OWASP/cornucopia/issues/2568). ## CR6: Romain can read and modify unencrypted data in memory or in transit (e.g., cryptographic secrets, credentials, session identifiers, personal and commercially-sensitive data), in use or in communications within the application, or between the application and users, or between the application and external systems. diff --git a/copi.owasp.org/assets/js/app.js b/copi.owasp.org/assets/js/app.js index 7b04f90f7..71af98231 100644 --- a/copi.owasp.org/assets/js/app.js +++ b/copi.owasp.org/assets/js/app.js @@ -27,12 +27,8 @@ const ulidPattern = /^[0-9A-HJKMNP-TV-Z]{26}$/; const isValidUlid = (value) => typeof value === 'string' && ulidPattern.test(value); -const updatePlayerSession = async ({ gameId, playerId, shouldPersist }) => { - if (!isValidUlid(gameId)) { - return; - } - - if (shouldPersist && !isValidUlid(playerId)) { +const exchangePlayerCapability = async (capability) => { + if (typeof capability !== 'string' || capability.length === 0) { return; } @@ -42,30 +38,64 @@ const updatePlayerSession = async ({ gameId, playerId, shouldPersist }) => { return; } - const url = shouldPersist - ? `/api/games/${gameId}/players/${playerId}/session` - : `/api/games/${gameId}/player-session`; - try { - await fetch(url, { - method: shouldPersist ? 'PUT' : 'DELETE', + const response = await fetch('/api/player-capabilities/exchange', { + method: 'POST', headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' }, credentials: 'same-origin', - cache: 'no-store' + cache: 'no-store', + body: JSON.stringify({ capability }) }); + + if (!response.ok) { + throw new Error('Player capability exchange was rejected'); + } + + const result = await response.json(); + + if (typeof result.redirect_to !== 'string' || !result.redirect_to.startsWith('/games/')) { + throw new Error('Player capability exchange returned an invalid redirect'); + } + + window.location.assign(result.redirect_to); } catch (error) { - console.warn('Unable to update player session', error); + console.warn('Unable to exchange player capability', error); } }; -const syncPlayerSession = (el) => updatePlayerSession({ - gameId: el?.dataset?.gameId, - playerId: el?.dataset?.playerId, - shouldPersist: el?.dataset?.storePlayerSession === 'true' -}); +const syncPlayerSession = (el) => { + const persistenceMode = el?.dataset?.storePlayerSession; + + if (persistenceMode !== 'false') { + return; + } + + const gameId = el?.dataset?.gameId; + const playerId = el?.dataset?.playerId; + + if (!isValidUlid(gameId) || !isValidUlid(playerId)) { + return; + } + + const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content"); + + if (!csrfToken) { + return; + } + + return fetch(`/api/games/${gameId}/players/${playerId}/session`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + cache: 'no-store' + }).catch(error => console.warn('Unable to clear player session', error)); +}; let Hooks = {} Hooks.DragDrop = { @@ -148,8 +178,10 @@ Hooks.DragDrop = { fetch('/api/games/' + gameId + '/players/' + playerId + '/card', { method: 'PUT', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken }, + credentials: 'same-origin', body: JSON.stringify({ dealt_card_id: dealtCardId }) @@ -197,6 +229,14 @@ Hooks.PersistPlayerSession = { } } +Hooks.ExchangePlayerCapability = { + mounted() { + this.handleEvent('exchange-player-capability', ({ capability }) => { + void exchangePlayerCapability(capability); + }); + } +} + 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 diff --git a/copi.owasp.org/assets/package-lock.json b/copi.owasp.org/assets/package-lock.json index a4594a85a..c343f27a5 100644 --- a/copi.owasp.org/assets/package-lock.json +++ b/copi.owasp.org/assets/package-lock.json @@ -40,7 +40,7 @@ "version": "4.3.0" }, "../deps/phoenix_live_view": { - "version": "1.2.6", + "version": "1.2.7", "license": "MIT", "dependencies": { "morphdom": "2.7.8" diff --git a/copi.owasp.org/assets/test/app.test.ts b/copi.owasp.org/assets/test/app.test.ts index 618f7b27f..9dec86abe 100644 --- a/copi.owasp.org/assets/test/app.test.ts +++ b/copi.owasp.org/assets/test/app.test.ts @@ -157,7 +157,11 @@ describe('assets/js/app.js', () => { '/api/games/game-1/players/player-1/card', expect.objectContaining({ method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'test-csrf' + }, + credentials: 'same-origin', body: JSON.stringify({ dealt_card_id: 'deal-1' }) }) ); @@ -358,14 +362,56 @@ describe('assets/js/app.js', () => { }); }); - it('persists the current player session for a game through the session endpoint', async () => { + it('exchanges a player capability through POST and navigates to the clean URL', async () => { + const assign = vi.fn(); + await vi.stubGlobal('location', { host: 'localhost:3000', assign } as unknown as Location); + const { config } = await loadApp(); + const hooks = config.hooks as { + ExchangePlayerCapability: { mounted: () => void }; + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + redirect_to: '/games/01KXJS02XPBB679E2W70JGRV0Z/players/01KXJS0JYKW9MJHBNBXE07YPHF' + }) + }); + + vi.stubGlobal('fetch', fetchMock); + + hooks.ExchangePlayerCapability.mounted.call({ + handleEvent: (_event: string, callback: (payload: { capability: string }) => void) => { + callback({ capability: 'signed-player-capability' }); + } + }); + + await vi.waitFor(() => expect(assign).toHaveBeenCalled()); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/player-capabilities/exchange', + expect.objectContaining({ + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'test-csrf' + }, + body: JSON.stringify({ capability: 'signed-player-capability' }) + }) + ); + expect(assign).toHaveBeenCalledWith( + '/games/01KXJS02XPBB679E2W70JGRV0Z/players/01KXJS0JYKW9MJHBNBXE07YPHF' + ); + }); + + it('clears the current player session through the session endpoint when persistence is disabled', async () => { await vi.stubGlobal('location', { host: 'localhost:3000' } as Location); document.body.innerHTML = `
`; const { config } = await loadApp(document.body.innerHTML); @@ -385,21 +431,20 @@ describe('assets/js/app.js', () => { expect(fetchMock).toHaveBeenCalledWith( '/api/games/01KXJS02XPBB679E2W70JGRV0Z/players/01KXJS0JYKW9MJHBNBXE07YPHF/session', expect.objectContaining({ - method: 'PUT', + method: 'DELETE', credentials: 'same-origin', cache: 'no-store' }) ); }); - it('clears the current player session through the session endpoint when persistence is disabled', async () => { + it('keeps the current player session when no persistence change is requested', async () => { await vi.stubGlobal('location', { host: 'localhost:3000' } as Location); document.body.innerHTML = ` `; const { config } = await loadApp(document.body.innerHTML); @@ -416,24 +461,17 @@ describe('assets/js/app.js', () => { await Promise.resolve(); - expect(fetchMock).toHaveBeenCalledWith( - '/api/games/01KXJS02XPBB679E2W70JGRV0Z/player-session', - expect.objectContaining({ - method: 'DELETE', - credentials: 'same-origin', - cache: 'no-store' - }) - ); + expect(fetchMock).not.toHaveBeenCalled(); }); - it('syncs the player session again when the hook is updated', async () => { + it('clears the player session again when the hook is updated', async () => { await vi.stubGlobal('location', { host: 'localhost:3000' } as Location); document.body.innerHTML = ` `; const { config } = await loadApp(document.body.innerHTML); @@ -453,21 +491,21 @@ describe('assets/js/app.js', () => { expect(fetchMock).toHaveBeenCalledWith( '/api/games/01KXJS02XPBB679E2W70JGRV0Z/players/01KXJS0JYKW9MJHBNBXE07YPHF/session', expect.objectContaining({ - method: 'PUT', + method: 'DELETE', credentials: 'same-origin', cache: 'no-store' }) ); }); - it('does not persist a player session when identifiers are malformed', async () => { + it('does not clear a player session when identifiers are malformed', async () => { await vi.stubGlobal('location', { host: 'localhost:3000' } as Location); document.body.innerHTML = ` `; const { config } = await loadApp(document.body.innerHTML); @@ -487,7 +525,7 @@ describe('assets/js/app.js', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('does not persist a player session when the CSRF token is missing', async () => { + it('does not clear a player session when the CSRF token is missing', async () => { await vi.stubGlobal('location', { host: 'localhost:3000' } as Location); document.head.innerHTML = ''; document.body.innerHTML = ` @@ -495,7 +533,7 @@ describe('assets/js/app.js', () => { id="player-session" data-game-id="01KXJS02XPBB679E2W70JGRV0Z" data-player-id="01KXJS0JYKW9MJHBNBXE07YPHF" - data-store-player-session="true" + data-store-player-session="false" > `; const { config } = await loadApp(document.body.innerHTML, ''); @@ -524,7 +562,7 @@ describe('assets/js/app.js', () => { id="player-session" data-game-id="01KXJS02XPBB679E2W70JGRV0Z" data-player-id="01KXJS0JYKW9MJHBNBXE07YPHF" - data-store-player-session="true" + data-store-player-session="false" `; const { config } = await loadApp(document.body.innerHTML); diff --git a/copi.owasp.org/config/config.exs b/copi.owasp.org/config/config.exs index a66b5ac95..7b4f5da4a 100644 --- a/copi.owasp.org/config/config.exs +++ b/copi.owasp.org/config/config.exs @@ -9,7 +9,10 @@ import Config config :copi, ecto_repos: [Copi.Repo], - generators: [timestamp_type: :utc_datetime] + generators: [timestamp_type: :utc_datetime], + player_capability_cluster_enabled: false, + postgres_session_store_enabled: false, + session_ttl_seconds: 7 * 24 * 60 * 60 config :copi, :resilience, retry_delay_ms: 750, diff --git a/copi.owasp.org/config/runtime.exs b/copi.owasp.org/config/runtime.exs index 105368178..444b5a2cd 100644 --- a/copi.owasp.org/config/runtime.exs +++ b/copi.owasp.org/config/runtime.exs @@ -19,7 +19,16 @@ import Config if System.get_env("PHX_SERVER") do config :copi, CopiWeb.Endpoint, server: true end -ssl_verify = if System.get_env("ECTO_SSL_VERIFY") in ~w(false 0), do: [ verify: :verify_none], else: false + +dns_cluster_query = System.get_env("DNS_CLUSTER_QUERY") + +config :copi, + dns_cluster_query: dns_cluster_query, + player_capability_cluster_enabled: is_binary(dns_cluster_query) and dns_cluster_query != "", + postgres_session_store_enabled: System.get_env("POSTGRES_SESSION_STORE_ENABLED") in ~w(true 1) + +ssl_verify = + if System.get_env("ECTO_SSL_VERIFY") in ~w(false 0), do: [verify: :verify_none], else: false if config_env() == :prod do database_url = @@ -52,8 +61,6 @@ if config_env() == :prod do host = System.get_env("PHX_HOST") || "example.com" port = String.to_integer(System.get_env("PORT") || "4000") - config :copi, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") - config :copi, CopiWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ @@ -66,9 +73,6 @@ if config_env() == :prod do ], secret_key_base: secret_key_base - config :copi, dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY") - - # ## SSL Support # # To get SSL working, you will need to add the `https` key diff --git a/copi.owasp.org/fly.toml b/copi.owasp.org/fly.toml index cfc8416e6..4879bbfed 100644 --- a/copi.owasp.org/fly.toml +++ b/copi.owasp.org/fly.toml @@ -23,6 +23,7 @@ kill_signal = 'SIGTERM' RATE_LIMIT_CONNECTION_LIMIT = '133' RATE_LIMIT_CONNECTION_WINDOW = '1' USE_FLY_CLIENT_IP = true + POSTGRES_SESSION_STORE_ENABLED = true [http_service] internal_port = 8080 diff --git a/copi.owasp.org/lib/copi/application.ex b/copi.owasp.org/lib/copi/application.ex index 7bcf453ac..6e9a2694c 100644 --- a/copi.owasp.org/lib/copi/application.ex +++ b/copi.owasp.org/lib/copi/application.ex @@ -9,12 +9,15 @@ defmodule Copi.Application do children = [ # Start the Ecto repository Copi.Repo, + # Remove expired PostgreSQL sessions once the repository is available + Copi.SessionCleanup, # Start the Telemetry supervisor CopiWeb.Telemetry, # Start the PubSub system {Phoenix.PubSub, name: Copi.PubSub}, # Start the RateLimiter for IP-based rate limiting (CAPEC-212 protection) Copi.RateLimiter, + Copi.PlayerCapabilityRegistry, # Start the DNS clustering {DNSCluster, query: Application.get_env(:copi, :dns_cluster_query) || :ignore}, # Start the Endpoint (http/https) diff --git a/copi.owasp.org/lib/copi/player_capability_consumption.ex b/copi.owasp.org/lib/copi/player_capability_consumption.ex new file mode 100644 index 000000000..092c9bd9f --- /dev/null +++ b/copi.owasp.org/lib/copi/player_capability_consumption.ex @@ -0,0 +1,10 @@ +defmodule Copi.PlayerCapabilityConsumption do + @moduledoc false + + use Ecto.Schema + + @primary_key {:digest, :binary, autogenerate: false} + schema "player_capability_consumptions" do + field :expires_at, :utc_datetime_usec + end +end diff --git a/copi.owasp.org/lib/copi/player_capability_registry.ex b/copi.owasp.org/lib/copi/player_capability_registry.ex new file mode 100644 index 000000000..1093fbe0d --- /dev/null +++ b/copi.owasp.org/lib/copi/player_capability_registry.ex @@ -0,0 +1,234 @@ +defmodule Copi.PlayerCapabilityRegistry do + @moduledoc false + + use GenServer + + import Ecto.Query + + alias Copi.PlayerCapabilityConsumption + alias Copi.Repo + + @retention_ms :timer.minutes(5) + @cleanup_interval_ms :timer.minutes(1) + @sync_interval_ms :timer.seconds(1) + @cluster_call_timeout_ms 1_000 + @default_global_name {__MODULE__, :cluster} + + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + def consume(capability) when is_binary(capability) do + consume(__MODULE__, capability) + end + + def consume(server, capability) when is_binary(capability) do + digest = :crypto.hash(:sha256, capability) + expires_at = System.system_time(:millisecond) + @retention_ms + + if postgres_enabled?() do + consume_in_postgres(digest, expires_at) + else + GenServer.call(server, {:consume, digest, expires_at}) + end + end + + @impl true + def init(opts) do + cluster_enabled = + Keyword.get( + opts, + :cluster_enabled, + Application.get_env(:copi, :player_capability_cluster_enabled, false) + ) and not postgres_enabled?() + + state = %{ + consumed: %{}, + cluster_enabled: cluster_enabled, + global_name: Keyword.get(opts, :global_name, @default_global_name) + } + + schedule_cleanup() + + if cluster_enabled do + :net_kernel.monitor_nodes(true, node_type: :visible) + schedule_sync(0) + end + + {:ok, state} + end + + @impl true + def handle_call({:consume, digest, expires_at}, _from, state) do + now = System.system_time(:millisecond) + state = remove_expired(state, now) + + case consume_with_mode(state, digest, expires_at) do + {result, updated_state} -> {:reply, result, updated_state} + end + end + + def handle_call({:consume_cluster, digest, expires_at}, _from, state) do + now = System.system_time(:millisecond) + state = remove_expired(state, now) + {result, updated_state} = consume_locally(state, digest, expires_at) + existing_expiry = Map.get(updated_state.consumed, digest) + + {:reply, {result, existing_expiry}, updated_state} + end + + def handle_call({:merge_consumed, incoming}, _from, state) when is_map(incoming) do + now = System.system_time(:millisecond) + state = remove_expired(state, now) + merged = merge_active(state.consumed, incoming, now) + + {:reply, merged, %{state | consumed: merged}} + end + + @impl true + def handle_info(:cluster_sync, %{cluster_enabled: true} = state) do + state = sync_with_cluster(state) + schedule_sync(@sync_interval_ms) + {:noreply, state} + end + + def handle_info({:nodeup, _node, _info}, %{cluster_enabled: true} = state) do + schedule_sync(0) + {:noreply, state} + end + + def handle_info({:nodedown, _node, _info}, state), do: {:noreply, state} + + @impl true + def handle_info(:cleanup, state) do + schedule_cleanup() + {:noreply, remove_expired(state, System.system_time(:millisecond))} + end + + defp consume_with_mode(%{cluster_enabled: false} = state, digest, expires_at) do + consume_locally(state, digest, expires_at) + end + + defp consume_with_mode(%{cluster_enabled: true} = state, digest, expires_at) do + if Map.has_key?(state.consumed, digest) do + {{:error, :already_used}, state} + else + {owner, state} = global_owner(state) + + cond do + owner == self() -> + consume_locally(state, digest, expires_at) + + is_pid(owner) -> + case call_cluster(owner, {:consume_cluster, digest, expires_at}) do + {:ok, {result, cluster_expiry}} -> + consumed = Map.put(state.consumed, digest, cluster_expiry) + {result, %{state | consumed: consumed}} + + :unavailable -> + consume_locally(state, digest, expires_at) + end + + true -> + consume_locally(state, digest, expires_at) + end + end + end + + defp consume_locally(state, digest, expires_at) do + if Map.has_key?(state.consumed, digest) do + {{:error, :already_used}, state} + else + {:ok, %{state | consumed: Map.put(state.consumed, digest, expires_at)}} + end + end + + defp sync_with_cluster(state) do + now = System.system_time(:millisecond) + state = remove_expired(state, now) + {owner, state} = global_owner(state) + + cond do + owner == self() -> + state + + is_pid(owner) -> + case call_cluster(owner, {:merge_consumed, state.consumed}) do + {:ok, merged} -> %{state | consumed: merge_active(state.consumed, merged, now)} + :unavailable -> state + end + + true -> + state + end + end + + defp global_owner(state) do + case :global.whereis_name(state.global_name) do + :undefined -> + case :global.register_name(state.global_name, self()) do + :yes -> {self(), state} + :no -> {:global.whereis_name(state.global_name), state} + end + + owner -> + {owner, state} + end + end + + defp call_cluster(owner, message) do + try do + {:ok, GenServer.call(owner, message, @cluster_call_timeout_ms)} + catch + :exit, _reason -> :unavailable + end + end + + defp merge_active(left, right, now) do + left + |> Map.merge(right, fn _digest, left_expiry, right_expiry -> + max(left_expiry, right_expiry) + end) + |> Map.reject(fn {_digest, expires_at} -> expires_at <= now end) + end + + defp remove_expired(state, now) do + %{ + state + | consumed: Map.reject(state.consumed, fn {_digest, expires_at} -> expires_at <= now end) + } + end + + defp schedule_cleanup do + Process.send_after(self(), :cleanup, @cleanup_interval_ms) + end + + defp schedule_sync(delay) do + Process.send_after(self(), :cluster_sync, delay) + end + + defp consume_in_postgres(digest, expires_at_ms) do + now = DateTime.utc_now() + expires_at = DateTime.from_unix!(expires_at_ms * 1_000, :microsecond) + + Repo.delete_all( + from consumption in PlayerCapabilityConsumption, + where: consumption.expires_at <= ^now + ) + + case Repo.insert_all( + PlayerCapabilityConsumption, + [%{digest: digest, expires_at: expires_at}], + on_conflict: :nothing, + conflict_target: [:digest] + ) do + {1, _} -> :ok + {0, _} -> {:error, :already_used} + end + end + + defp postgres_enabled? do + Application.get_env(:copi, :postgres_session_store_enabled, false) + end +end diff --git a/copi.owasp.org/lib/copi/release.ex b/copi.owasp.org/lib/copi/release.ex index 96dcdd49c..198cdaccc 100644 --- a/copi.owasp.org/lib/copi/release.ex +++ b/copi.owasp.org/lib/copi/release.ex @@ -4,18 +4,19 @@ defmodule Copi.Release do installed. """ @app :copi + @repo_stop_timeout 30_000 def migrate do load_app() for repo <- repos() do - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + {:ok, _, _} = with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end end def rollback(repo, version) do load_app() - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + {:ok, _, _} = with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) end defp repos do @@ -25,4 +26,28 @@ defmodule Copi.Release do defp load_app do Application.load(@app) end + + defp with_repo(repo, operation) do + config = repo.config() + mode = :temporary + + {:ok, ecto_started} = Application.ensure_all_started(:ecto_sql, mode) + {:ok, adapter_started} = repo.__adapter__().ensure_all_started(config, mode) + started = ecto_started ++ adapter_started + + case repo.start_link(pool_size: 2) do + {:ok, _pid} -> + try do + {:ok, operation.(repo), started} + after + repo.stop(@repo_stop_timeout) + end + + {:error, {:already_started, _pid}} -> + {:ok, operation.(repo), started} + + {:error, _reason} = error -> + error + end + end end diff --git a/copi.owasp.org/lib/copi/session_cleanup.ex b/copi.owasp.org/lib/copi/session_cleanup.ex new file mode 100644 index 000000000..dc683fbab --- /dev/null +++ b/copi.owasp.org/lib/copi/session_cleanup.ex @@ -0,0 +1,29 @@ +defmodule Copi.SessionCleanup do + @moduledoc false + + use Task + + import Ecto.Query + + alias Copi.Repo + alias Copi.SessionRecord + + def start_link(_opts) do + Task.start_link(__MODULE__, :run, []) + end + + def run do + if Application.get_env(:copi, :postgres_session_store_enabled, false) do + delete_expired_sessions(DateTime.utc_now()) + else + :ok + end + end + + def delete_expired_sessions(now) do + {deleted_count, _} = + Repo.delete_all(from session in SessionRecord, where: session.expires_at <= ^now) + + {:ok, deleted_count} + end +end diff --git a/copi.owasp.org/lib/copi/session_record.ex b/copi.owasp.org/lib/copi/session_record.ex new file mode 100644 index 000000000..af296b78c --- /dev/null +++ b/copi.owasp.org/lib/copi/session_record.ex @@ -0,0 +1,11 @@ +defmodule Copi.SessionRecord do + @moduledoc false + + use Ecto.Schema + + @primary_key {:id, :string, autogenerate: false} + schema "copi_sessions" do + field :data, :binary + field :expires_at, :utc_datetime_usec + end +end diff --git a/copi.owasp.org/lib/copi_web/controllers/api_controller.ex b/copi.owasp.org/lib/copi_web/controllers/api_controller.ex index df9d5fbf9..ee51049f9 100644 --- a/copi.owasp.org/lib/copi_web/controllers/api_controller.ex +++ b/copi.owasp.org/lib/copi_web/controllers/api_controller.ex @@ -1,82 +1,103 @@ defmodule CopiWeb.ApiController do use CopiWeb, :controller + alias Copi.PlayerCapabilityRegistry alias Copi.Cornucopia.Game alias Copi.Cornucopia.Player + alias CopiWeb.PlayerCapability + alias CopiWeb.PlayerSessions require Logger @resume_player_session_key "resume_player_session" - def persist_player_session(conn, %{"game_id" => game_id, "player_id" => player_id}) do - with :ok <- validate_ulid_format(game_id), + def exchange_player_capability(conn, %{"capability" => capability}) do + with {:ok, %{game_id: game_id, player_id: player_id}} <- PlayerCapability.verify(capability), + :ok <- validate_ulid_format(game_id), :ok <- validate_ulid_format(player_id), - {:ok, player} <- Player.find(player_id), - true <- player.game_id == game_id do + {:ok, %{game_id: ^game_id}} <- Player.find(player_id), + :ok <- PlayerCapabilityRegistry.consume(capability) do + player_sessions = + conn + |> get_session(@resume_player_session_key) + |> PlayerSessions.add(game_id, player_id) + conn - |> put_session(@resume_player_session_key, %{"game_id" => game_id, "player_id" => player_id}) + |> configure_session(renew: true) + |> put_session(@resume_player_session_key, player_sessions) |> put_resp_header("cache-control", "no-store") - |> json(%{"ok" => true}) + |> json(%{"redirect_to" => "/games/#{game_id}/players/#{player_id}"}) else - :invalid_format -> - conn - |> delete_session(@resume_player_session_key) - |> put_status(:bad_request) - |> put_resp_header("cache-control", "no-store") - |> json(%{"error" => "Invalid player session parameters"}) - - {:error, :not_found} -> - conn - |> delete_session(@resume_player_session_key) - |> put_status(:not_found) - |> put_resp_header("cache-control", "no-store") - |> json(%{"error" => "Player not found"}) - - {:error, reason} -> - Logger.debug("Transient player lookup failure while persisting player session for player_id=#{inspect(player_id)}, reason=#{inspect(reason)}") - - conn - |> put_status(:service_unavailable) - |> put_resp_header("cache-control", "no-store") - |> json(%{"error" => "Temporary service issue. Please retry."}) - - false -> + _ -> conn - |> delete_session(@resume_player_session_key) - |> put_status(:forbidden) + |> put_status(:unauthorized) |> put_resp_header("cache-control", "no-store") - |> json(%{"error" => "Player does not belong to this game"}) + |> json(%{"error" => "Invalid or expired player capability"}) end end - def persist_player_session(conn, _params) do + def exchange_player_capability(conn, _params) do conn |> put_status(:bad_request) |> put_resp_header("cache-control", "no-store") - |> json(%{"error" => "Invalid player session parameters"}) + |> json(%{"error" => "Invalid player capability request"}) end - def clear_player_session(conn, %{"game_id" => game_id}) do - case validate_ulid_format(game_id) do - :ok -> + def clear_player_session(conn, %{"game_id" => game_id, "player_id" => player_id}) do + with :ok <- validate_ulid_format(game_id), + :ok <- validate_ulid_format(player_id) do + remaining_sessions = + conn + |> get_session(@resume_player_session_key) + |> PlayerSessions.remove(game_id, player_id) + conn = - case get_session(conn, @resume_player_session_key) do - %{"game_id" => ^game_id} -> delete_session(conn, @resume_player_session_key) - _ -> conn + if remaining_sessions == [] do + delete_session(conn, @resume_player_session_key) + else + put_session(conn, @resume_player_session_key, remaining_sessions) end conn |> put_resp_header("cache-control", "no-store") |> json(%{"ok" => true}) - + else :invalid_format -> conn |> put_status(:bad_request) |> put_resp_header("cache-control", "no-store") - |> json(%{"error" => "Invalid game identifier"}) + |> json(%{"error" => "Invalid player session parameters"}) end end def play_card(conn, %{"game_id" => game_id, "player_id" => player_id, "dealt_card_id" => dealt_card_id}) do + if PlayerSessions.authorized?(get_session(conn, @resume_player_session_key), game_id, player_id) do + play_card_as(conn, game_id, player_id, dealt_card_id) + else + conn + |> put_status(:unauthorized) + |> json(%{"error" => "Valid player session required"}) + end + end + + def play_card(conn, %{"game_id" => game_id, "player_id" => player_id}) do + Logger.warning( + "Missing dealt_card_id for play_card request: game_id=#{inspect(game_id)}, player_id=#{inspect(player_id)}" + ) + + conn + |> put_status(:bad_request) + |> json(%{"error" => "Missing required parameter: dealt_card_id"}) + end + + def play_card(conn, params) do + Logger.warning("Invalid play_card request params: #{inspect(params)}") + + conn + |> put_status(:bad_request) + |> json(%{"error" => "Invalid request parameters"}) + end + + defp play_card_as(conn, game_id, player_id, dealt_card_id) do game_mod = Application.get_env(:copi, :api_game_module, Game) || Game repo_mod = Application.get_env(:copi, :api_repo_module, Copi.Repo) || Copi.Repo @@ -140,24 +161,6 @@ defmodule CopiWeb.ApiController do end end - def play_card(conn, %{"game_id" => game_id, "player_id" => player_id}) do - Logger.warning( - "Missing dealt_card_id for play_card request: game_id=#{inspect(game_id)}, player_id=#{inspect(player_id)}" - ) - - conn - |> put_status(:bad_request) - |> json(%{"error" => "Missing required parameter: dealt_card_id"}) - end - - def play_card(conn, params) do - Logger.warning("Invalid play_card request params: #{inspect(params)}") - - conn - |> put_status(:bad_request) - |> json(%{"error" => "Invalid request parameters"}) - end - def topic(game_id) do "game:#{game_id}" end diff --git a/copi.owasp.org/lib/copi_web/endpoint.ex b/copi.owasp.org/lib/copi_web/endpoint.ex index df602e957..f557e61b9 100644 --- a/copi.owasp.org/lib/copi_web/endpoint.ex +++ b/copi.owasp.org/lib/copi_web/endpoint.ex @@ -1,24 +1,54 @@ defmodule CopiWeb.Endpoint do use Phoenix.Endpoint, otp_app: :copi + @session_max_age Application.compile_env!(:copi, :session_ttl_seconds) + # The session will be stored in the cookie, encrypted, and signed. @session_options [ - store: :cookie, + store: CopiWeb.SessionStore, key: "_copi_key", signing_salt: "K7VwkdRe", encryption_salt: "vJ7hQp2L", http_only: true, secure: Mix.env() == :prod, - same_site: "Lax" + same_site: "Lax", + max_age: @session_max_age ] socket "/socket", CopiWeb.UserSocket, - websocket: [timeout: 45_000, connect_info: [:peer_data, session: @session_options, x_headers: ["x-forwarded-for", "fly-client-ip"]]], - longpoll: [connect_info: [:peer_data, session: @session_options, x_headers: ["x-forwarded-for", "fly-client-ip"]]] + websocket: [ + timeout: 45_000, + connect_info: [ + :peer_data, + session: @session_options, + x_headers: ["x-forwarded-for", "fly-client-ip"] + ] + ], + longpoll: [ + connect_info: [ + :peer_data, + session: @session_options, + x_headers: ["x-forwarded-for", "fly-client-ip"] + ] + ] socket "/live", Phoenix.LiveView.Socket, - websocket: [timeout: 45_000, connect_info: [:peer_data, session: @session_options, x_headers: ["x-forwarded-for", "fly-client-ip"]]], - longpoll: [connect_info: [:peer_data, session: @session_options, x_headers: ["x-forwarded-for", "fly-client-ip"]]] + websocket: [ + timeout: 45_000, + connect_info: [ + :peer_data, + session: @session_options, + x_headers: ["x-forwarded-for", "fly-client-ip"] + ] + ], + longpoll: [ + connect_info: [ + :peer_data, + session: @session_options, + x_headers: ["x-forwarded-for", "fly-client-ip"] + ] + ] + # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phx.digest diff --git a/copi.owasp.org/lib/copi_web/live/game_live/show.ex b/copi.owasp.org/lib/copi_web/live/game_live/show.ex index 4e4ee0065..9a43c7d9c 100644 --- a/copi.owasp.org/lib/copi_web/live/game_live/show.ex +++ b/copi.owasp.org/lib/copi_web/live/game_live/show.ex @@ -3,6 +3,7 @@ defmodule CopiWeb.GameLive.Show do alias Copi.Cornucopia.Game alias Copi.Cornucopia.DealtCard + alias CopiWeb.PlayerSessions alias CopiWeb.Resilience require Logger @@ -10,8 +11,8 @@ defmodule CopiWeb.GameLive.Show do @impl true def mount(params, session, socket) do ip = socket.assigns[:client_ip] || Map.get(session, "client_ip") || Copi.IPHelper.get_ip_from_socket(socket) - resume_player_id = resume_player_id_for_game(session, params["game_id"]) - {:ok, socket |> assign(:client_ip, ip) |> assign(:resume_player_id, resume_player_id)} + resume_player_ids = resume_player_ids_for_game(session, params["game_id"]) + {:ok, socket |> assign(:client_ip, ip) |> assign(:resume_player_ids, resume_player_ids)} end def on_mount(:default, _params, _session, socket) do @@ -74,7 +75,11 @@ defmodule CopiWeb.GameLive.Show do def handle_info(%{topic: message_topic, event: "game:updated", payload: updated_game}, socket) do cond do topic(updated_game.id) == message_topic -> - {:noreply, assign(socket, :game, updated_game) |> assign(:requested_round, updated_game.rounds_played + 1)} + {:noreply, + socket + |> assign_game(updated_game) + |> assign(:requested_round, updated_game.rounds_played + 1)} + true -> {:noreply, socket} end @@ -97,14 +102,14 @@ defmodule CopiWeb.GameLive.Show do {:ok, requested_round} -> {:noreply, socket - |> assign(:game, game) + |> assign_game(game) |> assign(:requested_round, requested_round) |> assign(:game_load_retry_count, 0)} {:error, _reason} -> {:noreply, socket - |> assign(:game, game) + |> assign_game(game) |> assign(:requested_round, current_round) |> assign(:game_load_retry_count, 0) |> put_flash(:error, "Invalid round value. Showing current round instead.")} @@ -190,16 +195,18 @@ defmodule CopiWeb.GameLive.Show do Application.get_env(:copi, :game_live_show_game_module, Game) || Game end - defp resume_player_id_for_game(session, game_id) do - case session["resume_player_session"] do - %{"game_id" => ^game_id, "player_id" => player_id} when is_binary(player_id) -> - case Ecto.ULID.cast(player_id) do - {:ok, valid_player_id} -> valid_player_id - :error -> nil - end + defp assign_game(socket, game) do + resume_player_ids = socket.assigns.resume_player_ids + resume_players = Enum.filter(game.players, &(&1.id in resume_player_ids)) - _ -> - nil - end + socket + |> assign(:game, game) + |> assign(:resume_players, resume_players) + end + + defp resume_player_ids_for_game(session, game_id) do + session["resume_player_session"] + |> PlayerSessions.player_ids_for_game(game_id) + |> Enum.filter(&(match?({:ok, _}, Ecto.ULID.cast(&1)))) end end diff --git a/copi.owasp.org/lib/copi_web/live/game_live/show.html.heex b/copi.owasp.org/lib/copi_web/live/game_live/show.html.heex index 9069b25d6..ea9e57ef7 100644 --- a/copi.owasp.org/lib/copi_web/live/game_live/show.html.heex +++ b/copi.owasp.org/lib/copi_web/live/game_live/show.html.heex @@ -2,15 +2,18 @@ <%= display_game_session(@game.edition) %> <%= @game.name %> -<%= if @resume_player_id do %> +<%= if @resume_players != [] do %>Resume your player session from this browser session.
- <.link - navigate={~p"/games/#{@game.id}/players/#{@resume_player_id}"} - class="mt-3 inline-flex rounded-md bg-amber-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-amber-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-700" - > - Resume player session in this browser - +Resume a player session from this browser.
+