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.

+
+ <.link + :for={player <- @resume_players} + navigate={~p"/games/#{@game.id}/players/#{player.id}"} + class="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.name %> + +
<% end %> diff --git a/copi.owasp.org/lib/copi_web/live/player_live/form_component.ex b/copi.owasp.org/lib/copi_web/live/player_live/form_component.ex index 99be8f5ed..b05fe2a6f 100644 --- a/copi.owasp.org/lib/copi_web/live/player_live/form_component.ex +++ b/copi.owasp.org/lib/copi_web/live/player_live/form_component.ex @@ -8,7 +8,7 @@ defmodule CopiWeb.PlayerLive.FormComponent do @impl true def render(assigns) do ~H""" -
+
<.header1> <%= @title %> @@ -113,11 +113,12 @@ defmodule CopiWeb.PlayerLive.FormComponent do {:ok, player} -> {:ok, updated_game} = Cornucopia.Game.find(socket.assigns.player.game_id) CopiWeb.Endpoint.broadcast(topic(updated_game.id), "game:updated", updated_game) + capability = CopiWeb.PlayerCapability.sign(player.game_id, player.id) {:noreply, socket |> assign(:game, updated_game) - |> push_navigate(to: ~p"/games/#{player.game_id}/players/#{player.id}")} + |> push_event("exchange-player-capability", %{capability: capability})} {:error, :game_already_started} -> # V15.4: Race condition caught by transaction - game started between check and insert diff --git a/copi.owasp.org/lib/copi_web/live/player_live/show.ex b/copi.owasp.org/lib/copi_web/live/player_live/show.ex index a193c8851..c7eb90836 100644 --- a/copi.owasp.org/lib/copi_web/live/player_live/show.ex +++ b/copi.owasp.org/lib/copi_web/live/player_live/show.ex @@ -8,6 +8,7 @@ defmodule CopiWeb.PlayerLive.Show do alias Copi.Cornucopia.Player alias Copi.Cornucopia.Game alias Copi.Cornucopia.DealtCard + alias CopiWeb.PlayerSessions alias CopiWeb.Resilience defmodule BadPlayerID do @@ -17,50 +18,71 @@ defmodule CopiWeb.PlayerLive.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) - {:ok, assign(socket, :client_ip, ip)} + {:ok, + socket + |> assign(:client_ip, ip) + |> assign(:player_sessions, session["resume_player_session"])} end @impl true - def handle_params(%{"id" => player_id}, _, socket) do - case validate_ulid_format(player_id) do - :ok -> + def handle_params(%{"game_id" => game_id, "id" => player_id} = params, _, socket) do + with :ok <- validate_ulid_format(game_id), + :ok <- validate_ulid_format(player_id) do + if authorized_player_url?(socket, params) do case player_module().find(player_id) do - {:ok, player} -> - case game_module().find(player.game_id) do - {:ok, game} -> - CopiWeb.Endpoint.subscribe(topic(player.game_id)) + {:ok, %{game_id: ^game_id} = player} -> + case game_module().find(game_id) do + {:ok, game} -> + CopiWeb.Endpoint.subscribe(topic(game_id)) + + {:noreply, + socket + |> assign(:game, game) + |> assign(:player, player) + |> assign(:player_load_retry_count, 0)} + + {:error, :not_found} -> + {:noreply, + socket + |> put_flash(:error, "Game not found.") + |> redirect(to: "/games")} + + {:error, reason} -> + handle_transient_player_load_failure(socket, params, reason) + end - {:noreply, - socket - |> assign(:game, game) - |> assign(:player, player) - |> assign(:player_load_retry_count, 0)} + {:ok, _player} -> + redirect_to_public_game(socket, game_id) {:error, :not_found} -> - {:noreply, - socket - |> put_flash(:error, "Game not found.") - |> redirect(to: "/games")} + raise Ecto.NoResultsError, queryable: Player {:error, reason} -> - handle_transient_player_load_failure(socket, player_id, reason) + handle_transient_player_load_failure(socket, params, reason) end - - {:error, :not_found} -> - raise Ecto.NoResultsError, queryable: Player - - {:error, reason} -> - handle_transient_player_load_failure(socket, player_id, reason) - end - + else + redirect_to_public_game(socket, game_id) + end + else :invalid_format -> raise BadPlayerID end end @impl true - def handle_info({:retry_player_show_load, player_id}, socket) do - handle_params(%{"id" => player_id}, nil, socket) + def handle_info({:retry_player_show_load, params}, socket) when is_map(params) do + handle_params(params, nil, socket) + end + + def handle_info({:retry_player_show_load, player_id}, socket) when is_binary(player_id) do + handle_params( + %{ + "game_id" => socket.assigns.game.id, + "id" => player_id + }, + nil, + socket + ) end @impl true @@ -270,7 +292,8 @@ defmodule CopiWeb.PlayerLive.Show do end end - defp handle_transient_player_load_failure(socket, player_id, reason) do + defp handle_transient_player_load_failure(socket, params, reason) do + player_id = params["id"] retry_count = socket.assigns[:player_load_retry_count] || 0 Logger.debug( @@ -279,7 +302,7 @@ defmodule CopiWeb.PlayerLive.Show do cond do socket.assigns[:game] && socket.assigns[:player] && retry_count < Resilience.max_player_load_retries() -> - Process.send_after(self(), {:retry_player_show_load, player_id}, Resilience.retry_delay_ms()) + Process.send_after(self(), {:retry_player_show_load, params}, Resilience.retry_delay_ms()) {:noreply, socket @@ -312,6 +335,17 @@ defmodule CopiWeb.PlayerLive.Show do Application.get_env(:copi, :player_live_show_dealt_card_module, DealtCard) || DealtCard end + defp authorized_player_url?(socket, %{"game_id" => game_id, "id" => player_id}) do + PlayerSessions.authorized?(socket.assigns.player_sessions, game_id, player_id) + end + + defp redirect_to_public_game(socket, game_id) do + {:noreply, + socket + |> put_flash(:error, "This player link is not available in this browser session.") + |> redirect(to: "/games/#{game_id}")} + end + defp validate_ulid_format(id) when is_binary(id) do case String.length(id) do 26 -> diff --git a/copi.owasp.org/lib/copi_web/live/player_live/show.html.heex b/copi.owasp.org/lib/copi_web/live/player_live/show.html.heex index 1b1a0b87d..f8013bcb8 100644 --- a/copi.owasp.org/lib/copi_web/live/player_live/show.html.heex +++ b/copi.owasp.org/lib/copi_web/live/player_live/show.html.heex @@ -3,7 +3,12 @@ phx-hook="PersistPlayerSession" data-game-id={@game.id} data-player-id={@player.id} - data-store-player-session={if is_nil(@game.finished_at), do: "true", else: "false"} + data-store-player-session={ + cond do + @game.finished_at -> "false" + true -> nil + end + } class="hidden" >
diff --git a/copi.owasp.org/lib/copi_web/player_capability.ex b/copi.owasp.org/lib/copi_web/player_capability.ex new file mode 100644 index 000000000..f42437fd9 --- /dev/null +++ b/copi.owasp.org/lib/copi_web/player_capability.ex @@ -0,0 +1,34 @@ +defmodule CopiWeb.PlayerCapability do + @moduledoc false + + @salt "player-capability" + @max_age 5 * 60 + + def sign(game_id, player_id) do + Phoenix.Token.sign(CopiWeb.Endpoint, @salt, %{ + "purpose" => "player", + "game_id" => game_id, + "player_id" => player_id + }) + end + + def verify(token) when is_binary(token) do + case Phoenix.Token.verify(CopiWeb.Endpoint, @salt, token, max_age: @max_age) do + {:ok, %{"purpose" => "player", "game_id" => game_id, "player_id" => player_id}} + when is_binary(game_id) and is_binary(player_id) -> + {:ok, %{game_id: game_id, player_id: player_id}} + + _ -> + {:error, :invalid} + end + end + + def verify(_token), do: {:error, :invalid} + + def verify(token, game_id, player_id) do + case verify(token) do + {:ok, %{game_id: ^game_id, player_id: ^player_id}} -> :ok + _ -> {:error, :invalid} + end + end +end diff --git a/copi.owasp.org/lib/copi_web/player_sessions.ex b/copi.owasp.org/lib/copi_web/player_sessions.ex new file mode 100644 index 000000000..3dc3aed5b --- /dev/null +++ b/copi.owasp.org/lib/copi_web/player_sessions.ex @@ -0,0 +1,51 @@ +defmodule CopiWeb.PlayerSessions do + @moduledoc false + + @max_entries 20 + + def add(session_value, game_id, player_id) do + entry = %{"game_id" => game_id, "player_id" => player_id} + + session_value + |> entries() + |> Enum.reject(&(&1 == entry)) + |> then(&[entry | &1]) + |> Enum.take(@max_entries) + end + + def authorized?(session_value, game_id, player_id) do + Enum.any?(entries(session_value), fn entry -> + entry == %{"game_id" => game_id, "player_id" => player_id} + end) + end + + def player_ids_for_game(session_value, game_id) do + session_value + |> entries() + |> Enum.filter(&(&1["game_id"] == game_id)) + |> Enum.map(& &1["player_id"]) + end + + def remove(session_value, game_id, player_id) do + entry = %{"game_id" => game_id, "player_id" => player_id} + Enum.reject(entries(session_value), &(&1 == entry)) + end + + def entries(%{"game_id" => game_id, "player_id" => player_id}) + when is_binary(game_id) and is_binary(player_id) do + [%{"game_id" => game_id, "player_id" => player_id}] + end + + def entries(entries) when is_list(entries) do + Enum.filter(entries, fn + %{"game_id" => game_id, "player_id" => player_id} + when is_binary(game_id) and is_binary(player_id) -> + true + + _ -> + false + end) + end + + def entries(_session_value), do: [] +end diff --git a/copi.owasp.org/lib/copi_web/router.ex b/copi.owasp.org/lib/copi_web/router.ex index db760efa9..c3311f46a 100644 --- a/copi.owasp.org/lib/copi_web/router.ex +++ b/copi.owasp.org/lib/copi_web/router.ex @@ -49,17 +49,12 @@ defmodule CopiWeb.Router do get "/cards/:version/:id", CardController, :show end - scope "/api", CopiWeb do - pipe_through :api - - put "/games/:game_id/players/:player_id/card", ApiController, :play_card - end - scope "/api", CopiWeb do pipe_through :browser_api - put "/games/:game_id/players/:player_id/session", ApiController, :persist_player_session - delete "/games/:game_id/player-session", ApiController, :clear_player_session + post "/player-capabilities/exchange", ApiController, :exchange_player_capability + put "/games/:game_id/players/:player_id/card", ApiController, :play_card + delete "/games/:game_id/players/:player_id/session", ApiController, :clear_player_session end # Health check endpoint for Fly.io - no pipeline needed for plain text response diff --git a/copi.owasp.org/lib/copi_web/security_headers.ex b/copi.owasp.org/lib/copi_web/security_headers.ex index 1df70cfb1..82e65c691 100644 --- a/copi.owasp.org/lib/copi_web/security_headers.ex +++ b/copi.owasp.org/lib/copi_web/security_headers.ex @@ -1,6 +1,7 @@ defmodule CopiWeb.SecurityHeaders do def browser_headers do %{ + "referrer-policy" => "no-referrer", "content-security-policy" => "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-src 'self'; font-src 'self'; media-src 'self'; object-src 'self'; manifest-src 'self'; worker-src 'self';" } @@ -8,6 +9,7 @@ defmodule CopiWeb.SecurityHeaders do def live_reload_headers do %{ + "referrer-policy" => "no-referrer", "content-security-policy" => "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src 'self'; font-src 'self'; media-src 'self'; object-src 'self'; manifest-src 'self'; worker-src 'self';" } diff --git a/copi.owasp.org/lib/copi_web/session_store.ex b/copi.owasp.org/lib/copi_web/session_store.ex new file mode 100644 index 000000000..09afee2ba --- /dev/null +++ b/copi.owasp.org/lib/copi_web/session_store.ex @@ -0,0 +1,108 @@ +defmodule CopiWeb.SessionStore do + @moduledoc false + + @behaviour Plug.Session.Store + + import Ecto.Query + + alias Copi.Encrypted.Binary, as: EncryptedBinary + alias Copi.Repo + alias Copi.SessionRecord + + @postgres_session_id "postgres_session_id" + + @impl true + def init(opts) do + %{cookie: Plug.Session.COOKIE.init(opts)} + end + + @impl true + def get(conn, raw_cookie, %{cookie: cookie_opts}) do + if postgres_enabled?() do + get_from_postgres(conn, raw_cookie, cookie_opts) + else + Plug.Session.COOKIE.get(conn, raw_cookie, cookie_opts) + end + end + + @impl true + def put(conn, session_id, session, %{cookie: cookie_opts}) do + if postgres_enabled?() do + put_in_postgres(conn, session_id, session, cookie_opts) + else + Plug.Session.COOKIE.put(conn, session_id, session, cookie_opts) + end + end + + @impl true +def delete(conn, session_id, %{cookie: cookie_opts}) do + if postgres_enabled?() do + Repo.delete_all(from record in SessionRecord, where: record.id == ^session_id) + Plug.Session.COOKIE.delete(conn, session_id, cookie_opts) + else + Plug.Session.COOKIE.delete(conn, session_id, cookie_opts) + end +end + + defp get_from_postgres(conn, raw_cookie, cookie_opts) do + with {_cookie_id, %{@postgres_session_id => session_id}} when is_binary(session_id) <- + Plug.Session.COOKIE.get(conn, raw_cookie, cookie_opts), + %SessionRecord{} = record <- Repo.get(SessionRecord, session_id), + :gt <- DateTime.compare(record.expires_at, DateTime.utc_now()), + {:ok, session} when is_map(session) <- decrypt_database_session(record.data) do + {session_id, session} + else + _ -> {nil, %{}} + end + end + + defp put_in_postgres(conn, session_id, session, cookie_opts) do + session_id = session_id || new_session_id() + now = DateTime.utc_now() + expires_at = DateTime.add(now, session_ttl_seconds(), :second) + {:ok, data} = encrypt_database_session(session) + + Repo.insert!( + %SessionRecord{id: session_id, data: data, expires_at: expires_at}, + on_conflict: [set: [data: data, expires_at: expires_at]], + conflict_target: [:id] + ) + + Repo.delete_all(from record in SessionRecord, where: record.expires_at <= ^now) + + Plug.Session.COOKIE.put( + conn, + nil, + %{@postgres_session_id => session_id}, + cookie_opts + ) + end + + defp encrypt_database_session(session) do + session + |> :erlang.term_to_binary(compressed: 6) + |> EncryptedBinary.encrypt() + end + + defp decrypt_database_session(data) do + with {:ok, plaintext} <- EncryptedBinary.decrypt(data) do + {:ok, Plug.Crypto.non_executable_binary_to_term(plaintext)} + end + rescue + ArgumentError -> {:error, :invalid_session} + end + + defp new_session_id do + 32 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end + + defp postgres_enabled? do + Application.get_env(:copi, :postgres_session_store_enabled, false) + end + + defp session_ttl_seconds do + Application.fetch_env!(:copi, :session_ttl_seconds) + end +end diff --git a/copi.owasp.org/priv/repo/migrations/20260717000001_create_security_session_tables.exs b/copi.owasp.org/priv/repo/migrations/20260717000001_create_security_session_tables.exs new file mode 100644 index 000000000..b7d67d0c9 --- /dev/null +++ b/copi.owasp.org/priv/repo/migrations/20260717000001_create_security_session_tables.exs @@ -0,0 +1,20 @@ +defmodule Copi.Repo.Migrations.CreateSecuritySessionTables do + use Ecto.Migration + + def change do + create table(:copi_sessions, primary_key: false) do + add :id, :string, primary_key: true + add :data, :binary, null: false + add :expires_at, :utc_datetime_usec, null: false + end + + create index(:copi_sessions, [:expires_at]) + + create table(:player_capability_consumptions, primary_key: false) do + add :digest, :binary, primary_key: true + add :expires_at, :utc_datetime_usec, null: false + end + + create index(:player_capability_consumptions, [:expires_at]) + end +end diff --git a/copi.owasp.org/test/copi/player_capability_registry_test.exs b/copi.owasp.org/test/copi/player_capability_registry_test.exs new file mode 100644 index 000000000..11f50efdf --- /dev/null +++ b/copi.owasp.org/test/copi/player_capability_registry_test.exs @@ -0,0 +1,148 @@ +defmodule Copi.PlayerCapabilityRegistryTest do + use ExUnit.Case, async: false + + alias Copi.PlayerCapabilityRegistry + + setup do + :sys.replace_state(PlayerCapabilityRegistry, fn state -> + %{state | consumed: %{}} + end) + + :ok + end + + test "consumes a capability only once" do + capability = "one-time-capability" + + assert :ok = PlayerCapabilityRegistry.consume(capability) + assert {:error, :already_used} = PlayerCapabilityRegistry.consume(capability) + end + + test "stores a digest instead of the capability" do + capability = "sensitive-capability" + + assert :ok = PlayerCapabilityRegistry.consume(capability) + + state = :sys.get_state(PlayerCapabilityRegistry) + refute Map.has_key?(state.consumed, capability) + assert Map.has_key?(state.consumed, :crypto.hash(:sha256, capability)) + end + + test "allows exactly one concurrent consumer" do + capability = "concurrent-capability" + + results = + 1..10 + |> Enum.map(fn _ -> Task.async(fn -> PlayerCapabilityRegistry.consume(capability) end) end) + |> Task.await_many() + + assert Enum.count(results, &(&1 == :ok)) == 1 + assert Enum.count(results, &(&1 == {:error, :already_used})) == 9 + end + + test "removes expired digests before consuming" do + capability = "expired-registry-entry" + digest = :crypto.hash(:sha256, capability) + + :sys.replace_state(PlayerCapabilityRegistry, fn state -> + %{state | consumed: %{digest => System.system_time(:millisecond) - 1}} + end) + + assert :ok = PlayerCapabilityRegistry.consume(capability) + end + + test "uses local replay protection by default" do + state = :sys.get_state(PlayerCapabilityRegistry) + + refute state.cluster_enabled + assert :undefined == :global.whereis_name(state.global_name) + end + + test "cluster mode merges active entries before accepting a capability" do + name = :cluster_registry_test + global_name = {:cluster_registry_test, System.unique_integer([:positive])} + capability = "capability-consumed-on-another-node" + digest = :crypto.hash(:sha256, capability) + expires_at = System.system_time(:millisecond) + :timer.minutes(5) + + start_supervised!( + {PlayerCapabilityRegistry, name: name, cluster_enabled: true, global_name: global_name} + ) + + assert %{^digest => ^expires_at} = + GenServer.call(name, {:merge_consumed, %{digest => expires_at}}) + + assert {:error, :already_used} = PlayerCapabilityRegistry.consume(name, capability) + send(name, :cluster_sync) + assert eventually(fn -> is_pid(:global.whereis_name(global_name)) end) + end + + test "cluster mode falls back to local replay protection when the global owner is unavailable" do + name = :cluster_fallback_registry_test + global_name = {:cluster_fallback_registry_test, System.unique_integer([:positive])} + unavailable_owner = spawn(fn -> Process.sleep(:infinity) end) + :yes = :global.register_name(global_name, unavailable_owner) + + start_supervised!( + {PlayerCapabilityRegistry, name: name, cluster_enabled: true, global_name: global_name} + ) + + assert :ok = PlayerCapabilityRegistry.consume(name, "fallback-capability") + + assert {:error, :already_used} = + PlayerCapabilityRegistry.consume(name, "fallback-capability") + + Process.exit(unavailable_owner, :kill) + end + + test "cluster mode synchronizes a locally consumed digest with the global owner" do + owner_name = :cluster_owner_registry_test + follower_name = :cluster_follower_registry_test + global_name = {:cluster_sync_registry_test, System.unique_integer([:positive])} + capability = "locally-consumed-during-partition" + digest = :crypto.hash(:sha256, capability) + expires_at = System.system_time(:millisecond) + :timer.minutes(5) + + start_supervised!( + Supervisor.child_spec( + {PlayerCapabilityRegistry, + name: owner_name, cluster_enabled: true, global_name: global_name}, + id: owner_name + ) + ) + + send(owner_name, :cluster_sync) + assert eventually(fn -> :global.whereis_name(global_name) == Process.whereis(owner_name) end) + + start_supervised!( + Supervisor.child_spec( + {PlayerCapabilityRegistry, + name: follower_name, cluster_enabled: true, global_name: global_name}, + id: follower_name + ) + ) + + GenServer.call(follower_name, {:merge_consumed, %{digest => expires_at}}) + send(follower_name, :cluster_sync) + + assert eventually(fn -> + match?( + {:error, :already_used}, + PlayerCapabilityRegistry.consume(owner_name, capability) + ) + end) + end + + defp eventually(check, attempts \\ 20) + + defp eventually(check, attempts) when attempts > 0 do + if check.() do + true + else + Process.sleep(10) + eventually(check, attempts - 1) + end + end + + defp eventually(_check, 0), do: false +end diff --git a/copi.owasp.org/test/copi_web/controllers/api_controller_test.exs b/copi.owasp.org/test/copi_web/controllers/api_controller_test.exs index 5054f5484..7bdd7d7ab 100644 --- a/copi.owasp.org/test/copi_web/controllers/api_controller_test.exs +++ b/copi.owasp.org/test/copi_web/controllers/api_controller_test.exs @@ -31,7 +31,7 @@ defmodule CopiWeb.ApiControllerTest do end end - setup do + setup %{conn: conn} do old_game_mod = Application.get_env(:copi, :api_game_module) old_repo_mod = Application.get_env(:copi, :api_repo_module) old_game_mode = Application.get_env(:copi, :api_game_stub_mode) @@ -59,7 +59,11 @@ defmodule CopiWeb.ApiControllerTest do Application.put_env(:copi, :api_game_stub_step, old_step) end) - %{game: game, player: player, dealt_card: dealt_card} + conn = init_test_session(conn, %{ + "resume_player_session" => %{"game_id" => game.id, "player_id" => player.id} + }) + + %{conn: conn, game: game, player: player, dealt_card: dealt_card} end test "play_card success", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do @@ -75,6 +79,28 @@ defmodule CopiWeb.ApiControllerTest do assert updated.played_in_round == 1 end + test "play_card requires a player session", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do + conn = + conn + |> clear_session() + |> put("/api/games/#{game.id}/players/#{player.id}/card", %{ + "dealt_card_id" => to_string(dealt_card.id) + }) + + assert json_response(conn, 401)["error"] == "Valid player session required" + assert Repo.get(DealtCard, dealt_card.id).played_in_round == nil + end + + test "play_card rejects a different player than the signed-in player", %{conn: conn, game: game} do + {:ok, other_player} = Cornucopia.create_player(%{name: "Other Player", game_id: game.id}) + + conn = put(conn, "/api/games/#{game.id}/players/#{other_player.id}/card", %{ + "dealt_card_id" => "1" + }) + + assert json_response(conn, 401)["error"] == "Valid player session required" + end + test "play_card fails if card already played", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do {:ok, _} = Repo.update(Ecto.Changeset.change(dealt_card, played_in_round: 1)) @@ -87,7 +113,7 @@ defmodule CopiWeb.ApiControllerTest do assert json_response(conn, 406)["error"] == "Card already played" end - test "play_card returns 404 when dealt card not found for player", %{conn: conn, game: game} do + test "play_card rejects a player from another game", %{conn: conn, game: game} do {:ok, other_game} = Cornucopia.create_game(%{name: "Other Game"}) {:ok, other_player} = Cornucopia.create_player(%{name: "Other", game_id: other_game.id}) {:ok, card2} = Cornucopia.create_card(%{ @@ -105,7 +131,7 @@ defmodule CopiWeb.ApiControllerTest do "dealt_card_id" => to_string(other_dealt.id) }) - assert json_response(conn, 404)["error"] == "Player not found in this game" + assert json_response(conn, 401)["error"] == "Valid player session required" end test "play_card fails if player already played in round", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do @@ -127,24 +153,24 @@ defmodule CopiWeb.ApiControllerTest do assert json_response(conn, 403)["error"] == "Player already played a card in this round" end - test "play_card returns 404 when player_id doesn't belong to game", %{conn: conn, game: game} do + test "play_card rejects a player id that does not match the session", %{conn: conn, game: game} do conn = put(conn, "/api/games/#{game.id}/players/99999/card", %{ "game_id" => game.id, "player_id" => "99999", "dealt_card_id" => "1" }) - assert json_response(conn, 404)["error"] == "Player not found in this game" + assert json_response(conn, 401)["error"] == "Valid player session required" end - test "play_card returns 404 when game does not exist", %{conn: conn, player: player, dealt_card: dealt_card} do + test "play_card rejects a game id that does not match the session", %{conn: conn, player: player, dealt_card: dealt_card} do conn = put(conn, "/api/games/00000000000000000000000099/players/#{player.id}/card", %{ "game_id" => "00000000000000000000000099", "player_id" => player.id, "dealt_card_id" => to_string(dealt_card.id) }) - assert json_response(conn, 404)["error"] == "Could not find game" + assert json_response(conn, 401)["error"] == "Valid player session required" end test "play_card returns 404 when dealt card id is missing for valid player", %{conn: conn, game: game, player: player} do @@ -226,57 +252,221 @@ defmodule CopiWeb.ApiControllerTest do assert json_response(conn, 422)["error"] == "Could not play card" end - test "persist_player_session stores a single encrypted resume pointer in the session", %{conn: conn, game: game, player: player} do + test "exchange stores an encrypted player capability in the session", %{conn: conn, game: game, player: player} do + capability = CopiWeb.PlayerCapability.sign(game.id, player.id) + conn = conn |> init_test_session(%{}) - |> put("/api/games/#{game.id}/players/#{player.id}/session", %{ - "game_id" => game.id, - "player_id" => player.id - }) + |> post("/api/player-capabilities/exchange", %{"capability" => capability}) - assert json_response(conn, 200)["ok"] == true - assert get_session(conn, "resume_player_session") == %{"game_id" => game.id, "player_id" => player.id} + assert json_response(conn, 200)["redirect_to"] == "/games/#{game.id}/players/#{player.id}" + assert get_session(conn, "resume_player_session") == [ + %{"game_id" => game.id, "player_id" => player.id} + ] assert get_resp_header(conn, "cache-control") == ["no-store"] end - test "persist_player_session rejects malformed identifiers", %{conn: conn} do + test "exchange adds a capability and returns a clean player URL", %{ + conn: conn, + game: game, + player: player + } do + {:ok, second_player} = Cornucopia.create_player(%{name: "Second Player", game_id: game.id}) + {:ok, other_game} = Cornucopia.create_game(%{name: "Other Game"}) + {:ok, other_player} = Cornucopia.create_player(%{name: "Other Player", game_id: other_game.id}) + + existing_sessions = [ + %{"game_id" => game.id, "player_id" => player.id}, + %{"game_id" => other_game.id, "player_id" => other_player.id} + ] + + capability = CopiWeb.PlayerCapability.sign(game.id, second_player.id) + conn = conn - |> init_test_session(%{}) - |> put("/api/games/bad/players/also-bad/session", %{ - "game_id" => "bad", - "player_id" => "also-bad" - }) + |> init_test_session(%{"resume_player_session" => existing_sessions}) + |> post("/api/player-capabilities/exchange", %{"capability" => capability}) + + clean_path = "/games/#{game.id}/players/#{second_player.id}" + assert json_response(conn, 200)["redirect_to"] == clean_path + refute response(conn, 200) =~ capability + assert get_resp_header(conn, "cache-control") == ["no-store"] - assert json_response(conn, 400)["error"] == "Invalid player session parameters" - assert get_session(conn, "resume_player_session") == nil + assert get_session(conn, "resume_player_session") == [ + %{"game_id" => game.id, "player_id" => second_player.id}, + %{"game_id" => game.id, "player_id" => player.id}, + %{"game_id" => other_game.id, "player_id" => other_player.id} + ] + + assert Enum.any?(get_resp_header(conn, "set-cookie"), &String.contains?(&1, "max-age=604800")) end - test "persist_player_session rejects cross-game player session writes", %{conn: conn, game: game} do - {:ok, other_game} = Cornucopia.create_game(%{name: "Another Game"}) - {:ok, other_player} = Cornucopia.create_player(%{name: "Other Player", game_id: other_game.id}) + test "exchange rejects an invalid capability without changing existing sessions", %{ + conn: conn, + game: game, + player: player + } do + conn = post(conn, "/api/player-capabilities/exchange", %{"capability" => "invalid"}) + + assert json_response(conn, 401)["error"] == "Invalid or expired player capability" + assert get_session(conn, "resume_player_session") == %{ + "game_id" => game.id, + "player_id" => player.id + } + end + + test "exchange rejects a capability older than five minutes", %{ + conn: conn, + game: game, + player: player + } do + expired_capability = + Phoenix.Token.sign( + CopiWeb.Endpoint, + "player-capability", + %{"purpose" => "player", "game_id" => game.id, "player_id" => player.id}, + signed_at: System.system_time(:second) - 301 + ) conn = + post(conn, "/api/player-capabilities/exchange", %{ + "capability" => expired_capability + }) + + assert json_response(conn, 401)["error"] == "Invalid or expired player capability" + end + + test "exchange adds capabilities for the same game and other games", %{ + conn: conn, + game: game, + player: player + } do + {:ok, second_player} = Cornucopia.create_player(%{name: "Second Player", game_id: game.id}) + {:ok, other_game} = Cornucopia.create_game(%{name: "Other Game"}) + {:ok, other_player} = Cornucopia.create_player(%{name: "Other Game Player", game_id: other_game.id}) + + second_capability = CopiWeb.PlayerCapability.sign(game.id, second_player.id) + + second_conn = conn - |> init_test_session(%{}) - |> put("/api/games/#{game.id}/players/#{other_player.id}/session", %{ - "game_id" => game.id, - "player_id" => other_player.id + |> post("/api/player-capabilities/exchange", %{"capability" => second_capability}) + + assert json_response(second_conn, 200)["redirect_to"] == "/games/#{game.id}/players/#{second_player.id}" + + sessions_after_second = get_session(second_conn, "resume_player_session") + + assert sessions_after_second == [ + %{"game_id" => game.id, "player_id" => second_player.id}, + %{"game_id" => game.id, "player_id" => player.id} + ] + + other_capability = CopiWeb.PlayerCapability.sign(other_game.id, other_player.id) + + other_conn = + build_conn() + |> init_test_session(%{"resume_player_session" => sessions_after_second}) + |> post("/api/player-capabilities/exchange", %{"capability" => other_capability}) + + assert json_response(other_conn, 200)["redirect_to"] == "/games/#{other_game.id}/players/#{other_player.id}" + + assert get_session(other_conn, "resume_player_session") == [ + %{"game_id" => other_game.id, "player_id" => other_player.id}, + %{"game_id" => game.id, "player_id" => second_player.id}, + %{"game_id" => game.id, "player_id" => player.id} + ] + end + + test "session supports multiple players in one game and players in multiple games", %{ + conn: conn, + game: game, + player: player, + dealt_card: dealt_card + } do + {:ok, second_player} = Cornucopia.create_player(%{name: "Second Player", game_id: game.id}) + second_dealt_card = Repo.insert!(%DealtCard{player_id: second_player.id, card_id: dealt_card.card_id}) + + {:ok, other_game} = Cornucopia.create_game(%{name: "Other Game"}) + {:ok, other_player} = Cornucopia.create_player(%{name: "Other Game Player", game_id: other_game.id}) + other_dealt_card = Repo.insert!(%DealtCard{player_id: other_player.id, card_id: dealt_card.card_id}) + + sessions = [ + %{"game_id" => game.id, "player_id" => player.id}, + %{"game_id" => game.id, "player_id" => second_player.id}, + %{"game_id" => other_game.id, "player_id" => other_player.id} + ] + + second_player_conn = + conn + |> init_test_session(%{"resume_player_session" => sessions}) + |> put("/api/games/#{game.id}/players/#{second_player.id}/card", %{ + "dealt_card_id" => to_string(second_dealt_card.id) + }) + + assert json_response(second_player_conn, 200)["id"] == second_dealt_card.id + + other_game_conn = + build_conn() + |> init_test_session(%{"resume_player_session" => sessions}) + |> put("/api/games/#{other_game.id}/players/#{other_player.id}/card", %{ + "dealt_card_id" => to_string(other_dealt_card.id) }) - assert json_response(conn, 403)["error"] == "Player does not belong to this game" - assert get_session(conn, "resume_player_session") == nil + assert json_response(other_game_conn, 200)["id"] == other_dealt_card.id + + mismatched_pair_conn = + build_conn() + |> init_test_session(%{"resume_player_session" => sessions}) + |> put("/api/games/#{game.id}/players/#{other_player.id}/card", %{ + "dealt_card_id" => to_string(other_dealt_card.id) + }) + + assert json_response(mismatched_pair_conn, 401)["error"] == "Valid player session required" + end + + test "exchange rejects requests without a capability", %{conn: conn, game: game, player: player} do + conn = post(conn, "/api/player-capabilities/exchange", %{}) + + assert json_response(conn, 400)["error"] == "Invalid player capability request" + assert get_session(conn, "resume_player_session") == %{"game_id" => game.id, "player_id" => player.id} end - test "clear_player_session removes the stored resume pointer for the matching game", %{conn: conn, game: game, player: player} do + test "exchange rejects a replay without changing the second session", %{conn: conn, game: game, player: player} do + capability = CopiWeb.PlayerCapability.sign(game.id, player.id) + + first_conn = + conn + |> init_test_session(%{}) + |> post("/api/player-capabilities/exchange", %{"capability" => capability}) + + assert json_response(first_conn, 200)["redirect_to"] == "/games/#{game.id}/players/#{player.id}" + + replay_conn = + build_conn() + |> init_test_session(%{}) + |> post("/api/player-capabilities/exchange", %{"capability" => capability}) + + assert json_response(replay_conn, 401)["error"] == "Invalid or expired player capability" + assert get_session(replay_conn, "resume_player_session") == nil + end + + test "clear_player_session removes only the matching game and player pair", %{conn: conn, game: game, player: player} do + {:ok, other_player} = Cornucopia.create_player(%{name: "Other Player", game_id: game.id}) + conn = conn - |> init_test_session(%{"resume_player_session" => %{"game_id" => game.id, "player_id" => player.id}}) - |> delete("/api/games/#{game.id}/player-session") + |> init_test_session(%{ + "resume_player_session" => [ + %{"game_id" => game.id, "player_id" => player.id}, + %{"game_id" => game.id, "player_id" => other_player.id} + ] + }) + |> delete("/api/games/#{game.id}/players/#{player.id}/session") assert json_response(conn, 200)["ok"] == true - assert get_session(conn, "resume_player_session") == nil + assert get_session(conn, "resume_player_session") == [ + %{"game_id" => game.id, "player_id" => other_player.id} + ] assert get_resp_header(conn, "cache-control") == ["no-store"] end end \ No newline at end of file diff --git a/copi.owasp.org/test/copi_web/gameplay_flow_test.exs b/copi.owasp.org/test/copi_web/gameplay_flow_test.exs new file mode 100644 index 000000000..0103b2fbd --- /dev/null +++ b/copi.owasp.org/test/copi_web/gameplay_flow_test.exs @@ -0,0 +1,199 @@ +defmodule CopiWeb.GameplayFlowTest do + use CopiWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Copi.Cornucopia + alias Copi.Cornucopia.DealtCard + alias Copi.Cornucopia.Game + alias Copi.Cornucopia.Vote + alias Copi.Repo + alias CopiWeb.PlayerCapability + + @category "Integration" + + setup do + {:ok, game} = + Cornucopia.create_game(%{ + name: "Complete game flow", + edition: "webapp", + suits: [@category] + }) + + players = + for name <- ["Player One", "Player Two", "Player Three"] do + {:ok, player} = Cornucopia.create_player(%{name: name, game_id: game.id}) + player + end + + for number <- 1..6 do + {:ok, _card} = + Cornucopia.create_card(%{ + category: @category, + value: Integer.to_string(number), + description: "Integration card #{number}", + misc: "integration", + edition: "webapp", + external_id: "INTEGRATION_#{number}", + language: "en", + version: "3.0", + owasp_scp: [], + owasp_devguide: [], + owasp_asvs: [], + owasp_appsensor: [], + capec: [], + safecode: [], + owasp_mastg: [], + owasp_masvs: [] + }) + end + + %{game: game, players: players} + end + + test "one browser completes a round as three authorized players", %{ + conn: conn, + game: game, + players: players + } do + browser = exchange_capabilities(conn, game, players) + + public_response = get(browser, "/games/#{game.id}") + assert html_response(public_response, 200) =~ game.name + + csrf_token = csrf_token(html_response(public_response, 200)) + browser = public_response |> recycle() |> put_req_header("x-csrf-token", csrf_token) + + {:ok, watcher, waiting_html} = live(browser, "/games/#{game.id}") + assert waiting_html =~ "Start Game" + render_click(watcher, "start_game", %{}) + + {:ok, started_game} = Game.find(game.id) + assert started_game.started_at + + dealt_cards = + Map.new(started_game.players, fn player -> + assert length(player.dealt_cards) == 2 + {player.id, hd(player.dealt_cards)} + end) + + for player <- players do + clean_path = "/games/#{game.id}/players/#{player.id}" + {:ok, _player_view, player_html} = live(browser, clean_path) + assert player_html =~ player.name + + dealt_card = Map.fetch!(dealt_cards, player.id) + + response = + put(browser, "/api/games/#{game.id}/players/#{player.id}/card", %{ + "dealt_card_id" => Integer.to_string(dealt_card.id) + }) + + assert json_response(response, 200)["id"] == dealt_card.id + end + + {:ok, game_after_cards} = Game.find(game.id) + + assert Enum.all?(game_after_cards.players, fn player -> + Enum.count(player.dealt_cards, &(&1.played_in_round == 1)) == 1 + end) + + [first_player, second_player | _] = players + second_player_card = Map.fetch!(dealt_cards, second_player.id) + + {:ok, first_player_view, _html} = + live(browser, "/games/#{game.id}/players/#{first_player.id}") + + render_click(first_player_view, "toggle_vote", %{ + "dealt_card_id" => Integer.to_string(second_player_card.id) + }) + + assert Repo.get_by(Vote, + player_id: first_player.id, + dealt_card_id: second_player_card.id + ) + + render_click(first_player_view, "next_round", %{}) + + {:ok, round_two_game} = Game.find(game.id) + assert round_two_game.rounds_played == 1 + assert render(watcher) =~ "Round 2" + end + + test "public watching works but an unstored player pair cannot enter or play", %{ + conn: conn, + game: game, + players: [authorized_player, unauthorized_player | _] + } do + browser = exchange_capabilities(conn, game, [authorized_player]) + + public_response = get(browser, "/games/#{game.id}") + assert html_response(public_response, 200) =~ game.name + + clean_path = "/games/#{game.id}/players/#{unauthorized_player.id}" + player_response = get(recycle(public_response), clean_path) + assert redirected_to(player_response, 302) == "/games/#{game.id}" + + dealt_card = + Repo.insert!(%DealtCard{ + player_id: unauthorized_player.id, + card_id: create_extra_card!().id + }) + + csrf_token = csrf_token(html_response(public_response, 200)) + + card_response = + public_response + |> recycle() + |> put_req_header("x-csrf-token", csrf_token) + |> put("/api/games/#{game.id}/players/#{unauthorized_player.id}/card", %{ + "dealt_card_id" => Integer.to_string(dealt_card.id) + }) + + assert json_response(card_response, 401)["error"] == "Valid player session required" + assert Repo.reload(dealt_card).played_in_round == nil + end + + defp exchange_capabilities(conn, game, players) do + Enum.reduce(players, conn, fn player, browser -> + capability = PlayerCapability.sign(game.id, player.id) + response = + post(browser, "/api/player-capabilities/exchange", %{"capability" => capability}) + + clean_path = json_response(response, 200)["redirect_to"] + assert clean_path == "/games/#{game.id}/players/#{player.id}" + refute clean_path =~ capability + + recycle(response) + end) + end + + defp csrf_token(html) do + [_, token] = Regex.run(~r/ init_test_session(%{"resume_player_session" => %{"game_id" => game.id, "player_id" => player.id}}) + |> init_test_session(%{ + "resume_player_session" => [ + %{"game_id" => game.id, "player_id" => first_player.id}, + %{"game_id" => game.id, "player_id" => second_player.id}, + %{"game_id" => other_game.id, "player_id" => other_player.id} + ] + }) {:ok, _view, html} = live(conn, "/games/#{game.id}") - assert html =~ "Resume your player session from this browser session." - assert html =~ "/games/#{game.id}/players/#{player.id}" + assert html =~ "Resume a player session from this browser." + assert html =~ "Resume First Resume" + assert html =~ "Resume Second Resume" + assert html =~ "/games/#{game.id}/players/#{first_player.id}" + assert html =~ "/games/#{game.id}/players/#{second_player.id}" + refute html =~ "Resume Other Resume" + refute html =~ "/games/#{other_game.id}/players/#{other_player.id}" end test "handle_info with non-matching topic is no-op", %{conn: conn, game: game} do diff --git a/copi.owasp.org/test/copi_web/live/player_live/form_component_test.exs b/copi.owasp.org/test/copi_web/live/player_live/form_component_test.exs index 69005e666..53fbf7e06 100644 --- a/copi.owasp.org/test/copi_web/live/player_live/form_component_test.exs +++ b/copi.owasp.org/test/copi_web/live/player_live/form_component_test.exs @@ -18,12 +18,11 @@ defmodule CopiWeb.PlayerLive.FormComponentTest do test "allows player creation under limit", %{conn: conn, game: game} do {:ok, view, _html} = live(conn, "/games/#{game.id}/players/new") - result = view - |> form("#player-form", player: %{name: "Test Player", game_id: game.id}) - |> render_submit() + view + |> form("#player-form", player: %{name: "Test Player", game_id: game.id}) + |> render_submit() - # Should redirect to player show page - assert {:ok, _view, _html} = follow_redirect(result, conn) + assert {:ok, _view, _html} = exchange_player_creation(view, conn) end test "shows error message when limit exceeded", %{conn: conn, game: game, ip: _ip} do @@ -61,18 +60,22 @@ defmodule CopiWeb.PlayerLive.FormComponentTest do assert html =~ "can" || html =~ "blank" || html =~ "required" || html =~ "invalid" # Should still be able to create a valid player (rate limit not consumed) - result = view - |> form("#player-form", player: %{name: "Valid Player", game_id: game.id}) - |> render_submit() + view + |> form("#player-form", player: %{name: "Valid Player", game_id: game.id}) + |> render_submit() - # Should redirect successfully - assert {:ok, _view, _html} = follow_redirect(result, conn) + assert {:ok, _view, _html} = exchange_player_creation(view, conn) end test "updates player successfully without rate limiting", %{conn: conn, game: game} do {:ok, player} = Cornucopia.create_player(%{name: "Original", game_id: game.id}) # Go to player show page which has Edit link + conn = + init_test_session(conn, %{ + "resume_player_session" => [%{"game_id" => game.id, "player_id" => player.id}] + }) + {:ok, view, _html} = live(conn, "/games/#{game.id}/players/#{player.id}") # Verify player name is displayed @@ -176,4 +179,18 @@ defmodule CopiWeb.PlayerLive.FormComponentTest do assert updated_socket.assigns.form != nil end end + + defp exchange_player_creation(view, conn) do + assert_push_event(view, "exchange-player-capability", %{capability: capability}) + + exchange_response = + post(conn, "/api/player-capabilities/exchange", %{"capability" => capability}) + + clean_path = json_response(exchange_response, 200)["redirect_to"] + refute clean_path =~ capability + + exchange_response + |> recycle() + |> live(clean_path) + end end diff --git a/copi.owasp.org/test/copi_web/live/player_live/show_test.exs b/copi.owasp.org/test/copi_web/live/player_live/show_test.exs index 19c1e1cfb..c6369df8e 100644 --- a/copi.owasp.org/test/copi_web/live/player_live/show_test.exs +++ b/copi.owasp.org/test/copi_web/live/player_live/show_test.exs @@ -44,10 +44,23 @@ defmodule CopiWeb.PlayerLive.ShowTest do @game_attrs %{name: "show test game"} - defp create_player(_) do + defp create_player(%{conn: conn}) do {:ok, game} = Cornucopia.create_game(@game_attrs) {:ok, player} = Cornucopia.create_player(%{name: "Player 1", game_id: game.id}) - %{player: player} + conn = init_test_session(conn, %{ + "resume_player_session" => [%{"game_id" => game.id, "player_id" => player.id}] + }) + %{conn: conn, player: player} + end + + defp player_url(game_id, player_id) do + "/games/#{game_id}/players/#{player_id}" + end + + defp authorize_player(conn, game_id, player_id) do + init_test_session(conn, %{ + "resume_player_session" => [%{"game_id" => game_id, "player_id" => player_id}] + }) end defp create_game_with_dealt_card(game_name, card_ext_id) do @@ -371,7 +384,7 @@ defmodule CopiWeb.PlayerLive.ShowTest do Application.put_env(:copi, :player_live_show_dealt_card_module, DealtCardStub) Application.put_env(:copi, :player_live_show_dealt_card_stub_mode, :transient) - {:ok, view, _html} = live(conn, "/games/#{game.id}/players/#{player.id}") + {:ok, view, _html} = live(authorize_player(conn, game.id, player.id), player_url(game.id, player.id)) render_click(view, "toggle_vote", %{"dealt_card_id" => to_string(dealt.id)}) assert render(view) =~ "Temporary issue loading card. Please try again." @@ -383,7 +396,7 @@ defmodule CopiWeb.PlayerLive.ShowTest do Application.put_env(:copi, :player_live_show_player_module, PlayerStub) Application.put_env(:copi, :player_live_show_player_stub_mode, :real) - {:ok, view, _html} = live(conn, "/games/#{game.id}/players/#{player.id}") + {:ok, view, _html} = live(authorize_player(conn, game.id, player.id), player_url(game.id, player.id)) Application.put_env(:copi, :player_live_show_player_stub_mode, :transient) send(view.pid, {:retry_player_show_load, player.id}) @@ -404,7 +417,7 @@ defmodule CopiWeb.PlayerLive.ShowTest do Application.put_env(:copi, :player_live_show_dealt_card_module, DealtCardStub) Application.put_env(:copi, :player_live_show_dealt_card_stub_mode, :vote_conflict) - {:ok, view, _html} = live(conn, "/games/#{game.id}/players/#{player.id}") + {:ok, view, _html} = live(authorize_player(conn, game.id, player.id), player_url(game.id, player.id)) render_click(view, "toggle_vote", %{"dealt_card_id" => to_string(dealt.id)}) assert render(view) =~ game.name @@ -412,11 +425,33 @@ defmodule CopiWeb.PlayerLive.ShowTest do end describe "toggle_vote authorization" do + test "redirects when the URL selects a player not stored in the session", %{conn: conn} do + {:ok, game} = Cornucopia.create_game(%{name: "Player URL Authorization"}) + {:ok, authorized_player} = Cornucopia.create_player(%{name: "Authorized", game_id: game.id}) + {:ok, other_player} = Cornucopia.create_player(%{name: "Other", game_id: game.id}) + + conn = + init_test_session(conn, %{ + "resume_player_session" => [ + %{"game_id" => game.id, "player_id" => authorized_player.id} + ] + }) + + expected_path = "/games/#{game.id}" + + assert {:error, + {:redirect, + %{ + to: ^expected_path, + flash: %{"error" => "This player link is not available in this browser session."} + }}} = live(conn, "/games/#{game.id}/players/#{other_player.id}") + end + test "rejects cross-game vote and shows error flash", %{conn: conn} do {game1, player1, _dc1} = create_game_with_dealt_card("Auth Game One", "AUTH_G1_C1") {_game2, _player2, dc2} = create_game_with_dealt_card("Auth Game Two", "AUTH_G2_C1") - {:ok, view, _html} = live(conn, "/games/#{game1.id}/players/#{player1.id}") + {:ok, view, _html} = live(authorize_player(conn, game1.id, player1.id), player_url(game1.id, player1.id)) render_click(view, "toggle_vote", %{"dealt_card_id" => to_string(dc2.id)}) assert render(view) =~ "Invalid card selection" @@ -438,7 +473,7 @@ defmodule CopiWeb.PlayerLive.ShowTest do player_id: other_player.id, card_id: card2.id, played_in_round: 1 }) - {:ok, view, _html} = live(conn, "/games/#{game1.id}/players/#{player1.id}") + {:ok, view, _html} = live(authorize_player(conn, game1.id, player1.id), player_url(game1.id, player1.id)) render_click(view, "toggle_vote", %{"dealt_card_id" => to_string(dc2.id)}) {:ok, refreshed_card} = DealtCard.find(dc2.id) @@ -448,7 +483,7 @@ defmodule CopiWeb.PlayerLive.ShowTest do test "shows not found flash when toggling vote with missing dealt card", %{conn: conn} do {game1, player1, _dc1} = create_game_with_dealt_card("Auth Game Four", "AUTH_G4_C1") - {:ok, view, _html} = live(conn, "/games/#{game1.id}/players/#{player1.id}") + {:ok, view, _html} = live(authorize_player(conn, game1.id, player1.id), player_url(game1.id, player1.id)) render_click(view, "toggle_vote", %{"dealt_card_id" => "999999"}) assert render(view) =~ "Card not found. Please refresh and try again." @@ -457,7 +492,7 @@ defmodule CopiWeb.PlayerLive.ShowTest do test "shows error flash when toggling vote with invalid dealt card id format", %{conn: conn} do {game1, player1, _dc1} = create_game_with_dealt_card("Auth Game Five", "AUTH_G5_C1") - {:ok, view, _html} = live(conn, "/games/#{game1.id}/players/#{player1.id}") + {:ok, view, _html} = live(authorize_player(conn, game1.id, player1.id), player_url(game1.id, player1.id)) render_click(view, "toggle_vote", %{"dealt_card_id" => "invalid_card_id"}) assert render(view) =~ "Invalid card format. Please refresh and try again." @@ -465,7 +500,10 @@ defmodule CopiWeb.PlayerLive.ShowTest do test "returns 404 when player does not exist", %{conn: conn} do assert_error_sent 404, fn -> - get(conn, "/games/00000000000000000000000001/players/00000000000000000000000002") + get( + authorize_player(conn, "00000000000000000000000001", "00000000000000000000000002"), + player_url("00000000000000000000000001", "00000000000000000000000002") + ) end end @@ -492,7 +530,7 @@ defmodule CopiWeb.PlayerLive.ShowTest do test "keeps socket when player no longer exists", %{conn: conn} do {game, player, dc} = create_game_with_dealt_card("HandleInfo Missing Player", "HI_MP_1") - {:ok, view, _html} = live(conn, "/games/#{game.id}/players/#{player.id}") + {:ok, view, _html} = live(authorize_player(conn, game.id, player.id), player_url(game.id, player.id)) Copi.Repo.delete!(dc) {:ok, _} = Copi.Cornucopia.delete_player(player) @@ -507,7 +545,7 @@ defmodule CopiWeb.PlayerLive.ShowTest do test "retry player show load message does not crash", %{conn: conn} do {game, player, _dc} = create_game_with_dealt_card("Retry Player Show", "HI_RETRY_1") - {:ok, view, _html} = live(conn, "/games/#{game.id}/players/#{player.id}") + {:ok, view, _html} = live(authorize_player(conn, game.id, player.id), player_url(game.id, player.id)) send(view.pid, {:retry_player_show_load, player.id}) :timer.sleep(50) diff --git a/copi.owasp.org/test/copi_web/live/player_live_test.exs b/copi.owasp.org/test/copi_web/live/player_live_test.exs index 37e9f9b6b..d1396e698 100644 --- a/copi.owasp.org/test/copi_web/live/player_live_test.exs +++ b/copi.owasp.org/test/copi_web/live/player_live_test.exs @@ -60,9 +60,16 @@ defmodule CopiWeb.PlayerLiveTest do player end - defp create_player(_) do + defp create_player(%{conn: conn}) do player = fixture(:player) - %{player: player} + conn = + init_test_session(conn, %{ + "resume_player_session" => [ + %{"game_id" => player.game_id, "player_id" => player.id} + ] + }) + + %{conn: conn, player: player} end describe "player :index action" do @@ -106,11 +113,11 @@ defmodule CopiWeb.PlayerLiveTest do |> form("#player-form", player: @invalid_attrs) |> render_change() =~ "can't be blank" - {:ok, _, html} = - index_live - |> form("#player-form", player: %{name: "some updated name", game_id: player.game_id}) - |> render_submit() - |> follow_redirect(conn) + index_live + |> form("#player-form", player: %{name: "some updated name", game_id: player.game_id}) + |> render_submit() + + {:ok, _, html} = exchange_player_creation(index_live, conn) assert html =~ "Hi some updated name, waiting for the game to start..." assert html =~ "Hi some updated name, waiting for the game to start..." @@ -218,6 +225,20 @@ defmodule CopiWeb.PlayerLiveTest do end end + defp exchange_player_creation(view, conn) do + assert_push_event(view, "exchange-player-capability", %{capability: capability}) + + exchange_response = + post(conn, "/api/player-capabilities/exchange", %{"capability" => capability}) + + clean_path = json_response(exchange_response, 200)["redirect_to"] + refute clean_path =~ capability + + exchange_response + |> recycle() + |> live(clean_path) + end + describe "Show" do setup [:create_player] diff --git a/copi.owasp.org/test/copi_web/postgres_session_integration_test.exs b/copi.owasp.org/test/copi_web/postgres_session_integration_test.exs new file mode 100644 index 000000000..7db11c800 --- /dev/null +++ b/copi.owasp.org/test/copi_web/postgres_session_integration_test.exs @@ -0,0 +1,137 @@ +defmodule CopiWeb.PostgresSessionIntegrationTest do + use CopiWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Copi.Cornucopia + alias Copi.Encrypted.Binary, as: EncryptedBinary + alias Copi.PlayerCapabilityConsumption + alias Copi.PlayerCapabilityRegistry + alias Copi.Repo + alias Copi.SessionCleanup + alias Copi.SessionRecord + alias CopiWeb.PlayerCapability + + setup do + previous_value = Application.get_env(:copi, :postgres_session_store_enabled, false) + + on_exit(fn -> + Application.put_env(:copi, :postgres_session_store_enabled, previous_value) + end) + + {:ok, game} = Cornucopia.create_game(%{name: "Postgres session game"}) + {:ok, player} = Cornucopia.create_player(%{name: "Postgres player", game_id: game.id}) + + %{game: game, player: player} + end + + test "configured PostgreSQL mode persists the session and rejects replay after local state loss", + %{ + conn: conn, + game: game, + player: player + } do + Application.put_env(:copi, :postgres_session_store_enabled, true) + capability = PlayerCapability.sign(game.id, player.id) + + exchange_response = exchange_capability(conn, game.id, capability) + clean_path = json_response(exchange_response, 200)["redirect_to"] + + assert clean_path == "/games/#{game.id}/players/#{player.id}" + assert Repo.aggregate(SessionRecord, :count) == 1 + assert Repo.aggregate(PlayerCapabilityConsumption, :count) == 1 + + session_record = Repo.one!(SessionRecord) + assert binary_part(session_record.data, 0, 4) == "ENC1" + refute session_record.data =~ game.id + refute session_record.data =~ player.id + + assert {:ok, plaintext_session} = EncryptedBinary.decrypt(session_record.data) + database_session = Plug.Crypto.non_executable_binary_to_term(plaintext_session) + + assert database_session["resume_player_session"] == [ + %{"game_id" => game.id, "player_id" => player.id} + ] + + {:ok, _player_view, player_html} = + exchange_response + |> recycle() + |> live(clean_path) + + assert player_html =~ player.name + + :sys.replace_state(PlayerCapabilityRegistry, fn state -> + %{state | consumed: %{}} + end) + + replay_response = exchange_capability(recycle(exchange_response), game.id, capability) + assert json_response(replay_response, 401)["error"] == "Invalid or expired player capability" + assert Repo.aggregate(PlayerCapabilityConsumption, :count) == 1 + end + + test "default mode keeps session and replay state out of PostgreSQL", %{ + conn: conn, + game: game, + player: player + } do + Application.put_env(:copi, :postgres_session_store_enabled, false) + capability = PlayerCapability.sign(game.id, player.id) + + exchange_response = exchange_capability(conn, game.id, capability) + + assert json_response(exchange_response, 200)["redirect_to"] == + "/games/#{game.id}/players/#{player.id}" + + assert Repo.aggregate(SessionRecord, :count) == 0 + assert Repo.aggregate(PlayerCapabilityConsumption, :count) == 0 + end + + test "startup cleanup removes expired PostgreSQL sessions and keeps active sessions" do + Application.put_env(:copi, :postgres_session_store_enabled, true) + now = DateTime.utc_now() + + Repo.insert!(%SessionRecord{ + id: "expired-session", + data: "expired", + expires_at: DateTime.add(now, -1, :second) + }) + + Repo.insert!(%SessionRecord{ + id: "active-session", + data: "active", + expires_at: DateTime.add(now, Application.fetch_env!(:copi, :session_ttl_seconds), :second) + }) + + assert {:ok, 1} = SessionCleanup.run() + refute Repo.get(SessionRecord, "expired-session") + assert Repo.get(SessionRecord, "active-session") + end + + test "startup cleanup leaves sessions alone when PostgreSQL session storage is disabled" do + Application.put_env(:copi, :postgres_session_store_enabled, false) + + Repo.insert!(%SessionRecord{ + id: "disabled-cleanup-session", + data: "expired", + expires_at: DateTime.add(DateTime.utc_now(), -1, :second) + }) + + assert :ok = SessionCleanup.run() + assert Repo.get(SessionRecord, "disabled-cleanup-session") + end + + defp exchange_capability(conn, game_id, capability) do + public_response = get(conn, "/games/#{game_id}") + csrf_token = csrf_token(html_response(public_response, 200)) + + public_response + |> recycle() + |> put_req_header("x-csrf-token", csrf_token) + |> post("/api/player-capabilities/exchange", %{"capability" => capability}) + end + + defp csrf_token(html) do + [_, token] = Regex.run(~r/ put_req_header("accept", "application/json") |> put("/api/games/#{game.id}/players/#{player.id}/card", %{"dealt_card_id" => "123"}) - assert conn.status in [200, 400, 403, 404, 406, 422, 500] + assert json_response(conn, 401)["error"] == "Valid player session required" end end diff --git a/copi.owasp.org/test/copi_web/session_store_test.exs b/copi.owasp.org/test/copi_web/session_store_test.exs new file mode 100644 index 000000000..91d9777f0 --- /dev/null +++ b/copi.owasp.org/test/copi_web/session_store_test.exs @@ -0,0 +1,201 @@ +defmodule CopiWeb.SessionStoreTest do + use CopiWeb.ConnCase, async: false + + alias Copi.Encrypted.Binary, as: EncryptedBinary + alias Copi.Repo + alias Copi.SessionRecord + alias CopiWeb.SessionStore + + setup do + previous_postgres_enabled = Application.get_env(:copi, :postgres_session_store_enabled) + previous_session_ttl = Application.get_env(:copi, :session_ttl_seconds) + + on_exit(fn -> + restore_env(:postgres_session_store_enabled, previous_postgres_enabled) + restore_env(:session_ttl_seconds, previous_session_ttl) + end) + + opts = + SessionStore.init( + key: "_copi_key", + signing_salt: "K7VwkdRe", + encryption_salt: "vJ7hQp2L", + secret_key_base: String.duplicate("a", 64) + ) + + %{opts: opts} + end + + test "cookie mode stores, reads, and deletes sessions", %{conn: conn, opts: opts} do + Application.put_env(:copi, :postgres_session_store_enabled, false) + + session = %{ + "resume_player_session" => [ + %{"game_id" => "game-1", "player_id" => "player-1"} + ] + } + + stored_conn = SessionStore.put(conn, nil, session, opts) + raw_cookie = cookie_value(stored_conn) + + assert {session_id, ^session} = SessionStore.get(conn, raw_cookie, opts) + assert is_binary(session_id) + + deleted_conn = SessionStore.delete(conn, session_id, opts) + + assert deleted_conn.resp_cookies["_copi_key"].max_age == 0 + end + + test "postgres mode stores, restores, updates, and prunes expired sessions", %{ + conn: conn, + opts: opts + } do + Application.put_env(:copi, :postgres_session_store_enabled, true) + Application.put_env(:copi, :session_ttl_seconds, 3600) + + Repo.insert!(%SessionRecord{ + id: "expired-session", + data: "expired", + expires_at: DateTime.add(DateTime.utc_now(), -1, :second) + }) + + first_session = %{ + "resume_player_session" => [ + %{"game_id" => "game-1", "player_id" => "player-1"} + ] + } + + first_conn = SessionStore.put(conn, nil, first_session, opts) + first_cookie = cookie_value(first_conn) + + refute Repo.get(SessionRecord, "expired-session") + + [record] = Repo.all(SessionRecord) + assert binary_part(record.data, 0, 4) == "ENC1" + assert DateTime.compare(record.expires_at, DateTime.utc_now()) == :gt + assert {record.id, ^first_session} = SessionStore.get(conn, first_cookie, opts) + + updated_session = %{ + "resume_player_session" => [ + %{"game_id" => "game-2", "player_id" => "player-2"} + ] + } + + updated_conn = SessionStore.put(conn, record.id, updated_session, opts) + updated_cookie = cookie_value(updated_conn) + + assert Repo.aggregate(SessionRecord, :count) == 1 + assert {record.id, ^updated_session} = SessionStore.get(conn, updated_cookie, opts) + end + + test "postgres mode delete removes the backing record", %{conn: conn, opts: opts} do + Application.put_env(:copi, :postgres_session_store_enabled, true) + Application.put_env(:copi, :session_ttl_seconds, 3600) + + stored_conn = + SessionStore.put( + conn, + nil, + %{"resume_player_session" => [%{"game_id" => "game", "player_id" => "player"}]}, + opts + ) + + session_id = postgres_session_id(conn, cookie_value(stored_conn), opts) + + deleted_conn = SessionStore.delete(conn, session_id, opts) + + refute Repo.get(SessionRecord, session_id) + assert deleted_conn.resp_cookies["_copi_key"].max_age == 0 + end + + test "postgres mode returns an empty session when the cookie is missing", %{ + conn: conn, + opts: opts + } do + Application.put_env(:copi, :postgres_session_store_enabled, true) + + assert {nil, %{}} = SessionStore.get(conn, nil, opts) + end + + test "postgres mode returns an empty session when the record is missing", %{ + conn: conn, + opts: opts + } do + Application.put_env(:copi, :postgres_session_store_enabled, true) + + raw_cookie = postgres_cookie(conn, opts, "missing-session") + + assert {nil, %{}} = SessionStore.get(conn, raw_cookie, opts) + end + + test "postgres mode returns an empty session when the record is expired", %{ + conn: conn, + opts: opts + } do + Application.put_env(:copi, :postgres_session_store_enabled, true) + + Repo.insert!(%SessionRecord{ + id: "expired-session", + data: "expired", + expires_at: DateTime.add(DateTime.utc_now(), -1, :second) + }) + + raw_cookie = postgres_cookie(conn, opts, "expired-session") + + assert {nil, %{}} = SessionStore.get(conn, raw_cookie, opts) + end + + test "postgres mode returns an empty session when the payload cannot be decrypted", %{ + conn: conn, + opts: opts + } do + Application.put_env(:copi, :postgres_session_store_enabled, true) + + Repo.insert!(%SessionRecord{ + id: "undecryptable-session", + data: "not-encrypted", + expires_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + + raw_cookie = postgres_cookie(conn, opts, "undecryptable-session") + + assert {nil, %{}} = SessionStore.get(conn, raw_cookie, opts) + end + + test "postgres mode returns an empty session when decrypted data is not a serialized term", %{ + conn: conn, + opts: opts + } do + Application.put_env(:copi, :postgres_session_store_enabled, true) + + {:ok, invalid_term_blob} = EncryptedBinary.encrypt("not an erlang term") + + Repo.insert!(%SessionRecord{ + id: "invalid-term-session", + data: invalid_term_blob, + expires_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + + raw_cookie = postgres_cookie(conn, opts, "invalid-term-session") + + assert {nil, %{}} = SessionStore.get(conn, raw_cookie, opts) + end + + defp postgres_cookie(conn, opts, session_id) do + conn + |> Plug.Session.COOKIE.put(nil, %{"postgres_session_id" => session_id}, opts.cookie) + |> cookie_value() + end + + defp postgres_session_id(conn, raw_cookie, opts) do + {_cookie_id, %{"postgres_session_id" => session_id}} = + Plug.Session.COOKIE.get(conn, raw_cookie, opts.cookie) + + session_id + end + + defp cookie_value(conn), do: conn.resp_cookies["_copi_key"].value + + defp restore_env(key, nil), do: Application.delete_env(:copi, key) + defp restore_env(key, value), do: Application.put_env(:copi, key, value) +end