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/Dockerfile b/copi.owasp.org/Dockerfile index 1bc307a3c..4d9c6ddc6 100644 --- a/copi.owasp.org/Dockerfile +++ b/copi.owasp.org/Dockerfile @@ -26,7 +26,9 @@ WORKDIR /app # install hex + rebar RUN mix local.hex --force \ - && mix local.rebar --force + || mix archive.install github hexpm/hex branch latest --force +RUN mix local.rebar --force \ + || mix local.rebar rebar3 https://github.com/erlang/rebar3/releases/latest/download/rebar3 --force # set build ENV ENV MIX_ENV="prod" 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..127e7fe59 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. @@ -209,7 +242,7 @@ The Fly.io reverse proxy does not strip or rewrite untrusted X-Forwarded-For hea We are working on minimizing the probability of functionality misuse by implementing rate limiting on the creation of games and players (see: [issues/1877](https://github.com/OWASP/cornucopia/issues/1877)). Once that is taken care of, you should be able to configure these limits to prevent DoS attacks when hosting Copi yourself. It's vital that you limit the number of sockets the application accepts concurrently. On fly.io that is done in the following way: [fly.toml](https://github.com/OWASP/cornucopia/blob/fb9aae62531dde8db154729d0df4aa28a3400063/copi.owasp.org/fly.toml#L27) A 30 socket limit for Copi should allow you to handle 20.000 requests per min if you have 2 single cpu nodes Which we have tested against that setup. -Use of Fly-Client-IP has been considered. We are looking into implementing this for Copi (see: https://github.com/OWASP/cornucopia/issues/3227). +When Copi receives traffic directly from Fly Proxy, set `USE_FLY_CLIENT_IP=true`. [Fly.io documents `Fly-Client-IP`](https://fly.io/docs/networking/request-headers/#fly-client-ip) as the client IP address from Fly Proxy's perspective and says it may be a better choice than `X-Forwarded-For`, which must be treated with caution to avoid spoofing. Using it prevents Copi's rate limiter from trusting a client-controlled, left-most `X-Forwarded-For` value. If another reverse proxy is in front of Fly.io, `Fly-Client-IP` contains that proxy's address instead of the original client's address; in that deployment, parse `X-Forwarded-For` using an explicit trusted-proxy configuration. ### CK: Grant can utilize the application to deny service to some or all of its users 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/assets/test/playerCapability.test.ts b/copi.owasp.org/assets/test/playerCapability.test.ts new file mode 100644 index 000000000..04efefb75 --- /dev/null +++ b/copi.owasp.org/assets/test/playerCapability.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadApp } from './appHarness'; + +type CapabilityHook = { + ExchangePlayerCapability: { mounted: () => void }; +}; + +const mountCapabilityHook = ( + hooks: CapabilityHook, + capability: unknown +) => { + hooks.ExchangePlayerCapability.mounted.call({ + handleEvent: ( + _event: string, + callback: (payload: { capability: unknown }) => void + ) => callback({ capability }) + }); +}; + +describe('player capability exchange error handling', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + document.head.innerHTML = ''; + }); + + it('handles a rejected capability exchange without navigating', async () => { + const assign = vi.fn(); + vi.stubGlobal( + 'location', + { host: 'localhost:3000', assign } as unknown as Location + ); + + const { config } = await loadApp(); + const hooks = config.hooks as CapabilityHook; + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.stubGlobal('fetch', fetchMock); + + mountCapabilityHook(hooks, 'signed-player-capability'); + + await vi.waitFor(() => expect(warnSpy).toHaveBeenCalled()); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(assign).not.toHaveBeenCalled(); + }); + + it('rejects an invalid redirect returned by the exchange endpoint', async () => { + const assign = vi.fn(); + vi.stubGlobal( + 'location', + { host: 'localhost:3000', assign } as unknown as Location + ); + + const { config } = await loadApp(); + const hooks = config.hooks as CapabilityHook; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ redirect_to: 'https://example.org' }) + }) + ); + + mountCapabilityHook(hooks, 'signed-player-capability'); + + await vi.waitFor(() => expect(warnSpy).toHaveBeenCalled()); + expect(assign).not.toHaveBeenCalled(); + }); + + it.each([['empty', ''], ['non-string', undefined]])( + 'ignores a %s capability', + async (_description, capability) => { + vi.stubGlobal('location', { host: 'localhost:3000' } as Location); + + const { config } = await loadApp(); + const hooks = config.hooks as CapabilityHook; + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + mountCapabilityHook(hooks, capability); + + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it('does not exchange a capability when the CSRF token is missing', async () => { + vi.stubGlobal('location', { host: 'localhost:3000' } as Location); + + const { config } = await loadApp(); + const hooks = config.hooks as CapabilityHook; + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + document.querySelector('meta[name="csrf-token"]')?.remove(); + + mountCapabilityHook(hooks, 'signed-player-capability'); + + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); 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/controllers/player_handoff_controller.ex b/copi.owasp.org/lib/copi_web/controllers/player_handoff_controller.ex new file mode 100644 index 000000000..0d0c1188e --- /dev/null +++ b/copi.owasp.org/lib/copi_web/controllers/player_handoff_controller.ex @@ -0,0 +1,44 @@ +defmodule CopiWeb.PlayerHandoffController do + use CopiWeb, :controller + + alias Copi.Cornucopia.Player + alias Copi.PlayerCapabilityRegistry + alias CopiWeb.PlayerHandoff + alias CopiWeb.PlayerSessions + + @resume_player_session_key "resume_player_session" + + def redeem(conn, %{"token" => token}) do + with {:ok, %{game_id: game_id, player_id: player_id}} <- PlayerHandoff.verify(token), + :ok <- validate_ulid_format(game_id), + :ok <- validate_ulid_format(player_id), + {:ok, %{game_id: ^game_id}} <- Player.find(player_id), + :ok <- PlayerCapabilityRegistry.consume(token) do + player_sessions = + conn + |> get_session(@resume_player_session_key) + |> PlayerSessions.add(game_id, player_id) + + conn + |> configure_session(renew: true) + |> put_session(@resume_player_session_key, player_sessions) + |> put_resp_header("cache-control", "no-store") + |> redirect(to: "/games/#{game_id}/players/#{player_id}") + else + _ -> + conn + |> put_resp_header("cache-control", "no-store") + |> put_flash(:error, "This handoff link is invalid, expired, or has already been used.") + |> redirect(to: "/games") + end + end + + defp validate_ulid_format(id) when is_binary(id) do + with 26 <- String.length(id), + {:ok, _ulid} <- Ecto.ULID.cast(id) do + :ok + else + _ -> :invalid_format + end + end +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..a01d035f1 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,8 @@ defmodule CopiWeb.PlayerLive.Show do alias Copi.Cornucopia.Player alias Copi.Cornucopia.Game alias Copi.Cornucopia.DealtCard + alias CopiWeb.PlayerHandoff + alias CopiWeb.PlayerSessions alias CopiWeb.Resilience defmodule BadPlayerID do @@ -17,50 +19,72 @@ 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"]) + |> assign(:handoff_url, nil)} 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) - end - - {:error, :not_found} -> - 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 - + 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 @@ -152,6 +176,14 @@ defmodule CopiWeb.PlayerLive.Show do {:noreply, assign(socket, :game, updated_game)} end + @impl true + def handle_event("share_hand", _params, socket) do + token = PlayerHandoff.sign(socket.assigns.game.id, socket.assigns.player.id) + handoff_url = "#{socket.endpoint.url()}/player-handoffs/#{URI.encode(token)}" + + {:noreply, assign(socket, :handoff_url, handoff_url)} + end + @impl true def handle_event("toggle_vote", %{"dealt_card_id" => dealt_card_id}, socket) do # Parse dealt_card_id defensively - it comes from client params @@ -270,7 +302,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 +312,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 +345,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..cbeb1d1ef 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" >
@@ -19,6 +24,19 @@ <% else %> +
+ <.primary_button type="button" phx-click="share_hand"> + <.icon name="hero-share" class="mr-2 h-5 w-5" /> + Share your hand + + <%= if @handoff_url do %> +
+

This single-use link expires in 5 minutes.

+ <.copy_url_button uri={@handoff_url} /> +
+ <% end %> +
+ <%= if @game.started_at do %> <% first_card_played = Copi.Cornucopia.ordered_cards_played_in_round(@game, @game.rounds_played + 1) |> List.first %> 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_handoff.ex b/copi.owasp.org/lib/copi_web/player_handoff.ex new file mode 100644 index 000000000..c9413d637 --- /dev/null +++ b/copi.owasp.org/lib/copi_web/player_handoff.ex @@ -0,0 +1,39 @@ +defmodule CopiWeb.PlayerHandoff do + @moduledoc false + + @salt "player-handoff" + @max_age_seconds 5 * 60 + + def sign(game_id, player_id, opts \\ []) do + Phoenix.Token.sign( + CopiWeb.Endpoint, + @salt, + %{ + "purpose" => "player_handoff", + "game_id" => game_id, + "player_id" => player_id + }, + opts + ) + end + + def verify(token) when is_binary(token) do + case Phoenix.Token.verify(CopiWeb.Endpoint, @salt, token, max_age: @max_age_seconds) do + {:ok, + %{ + "purpose" => "player_handoff", + "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 max_age_seconds, do: @max_age_seconds +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..0390d16a6 100644 --- a/copi.owasp.org/lib/copi_web/router.ex +++ b/copi.owasp.org/lib/copi_web/router.ex @@ -29,6 +29,8 @@ defmodule CopiWeb.Router do get "/", PageController, :home + get "/player-handoffs/:token", PlayerHandoffController, :redeem + live "/games", GameLive.Index, :index live "/games/new", GameLive.Index, :new @@ -49,17 +51,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..718f8dc7e --- /dev/null +++ b/copi.owasp.org/lib/copi_web/session_store.ex @@ -0,0 +1,110 @@ +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, nil, _opts), do: {nil, %{}} + + 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..642e6a523 --- /dev/null +++ b/copi.owasp.org/test/copi/player_capability_registry_test.exs @@ -0,0 +1,167 @@ +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 + + test "cluster mode handles nodeup notifications" do + name = :cluster_nodeup_registry_test + global_name = {:cluster_nodeup_registry_test, System.unique_integer([:positive])} + + start_supervised!( + Supervisor.child_spec( + {PlayerCapabilityRegistry, name: name, cluster_enabled: true, global_name: global_name}, + id: name + ) + ) + + send(name, {:nodeup, :nonode@nohost, %{}}) + + assert eventually(fn -> + pid = Process.whereis(name) + is_pid(pid) and Process.alive?(pid) + 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/release_test.exs b/copi.owasp.org/test/copi/release_test.exs index 55fb422f9..b245b6f47 100644 --- a/copi.owasp.org/test/copi/release_test.exs +++ b/copi.owasp.org/test/copi/release_test.exs @@ -2,6 +2,16 @@ defmodule Copi.ReleaseTest do use ExUnit.Case, async: false import ExUnit.CaptureLog + defmodule FailingAdapter do + def ensure_all_started(_config, _mode), do: {:ok, []} + end + + defmodule FailingRepo do + def config, do: [] + def __adapter__, do: Copi.ReleaseTest.FailingAdapter + def start_link(_opts), do: {:error, :boom} + end + test "migrate runs with empty repo list" do old_repos = Application.get_env(:copi, :ecto_repos) @@ -53,4 +63,18 @@ defmodule Copi.ReleaseTest do assert failure_mode in [:ownership_error, :owner_exited] end) end + + test "migrate surfaces repo start errors that are not already_started" do + old_repos = Application.get_env(:copi, :ecto_repos) + + try do + Application.put_env(:copi, :ecto_repos, [FailingRepo]) + + assert_raise MatchError, fn -> + Copi.Release.migrate() + end + after + Application.put_env(:copi, :ecto_repos, old_repos || []) + end + end end diff --git a/copi.owasp.org/test/copi/session_cleanup_test.exs b/copi.owasp.org/test/copi/session_cleanup_test.exs new file mode 100644 index 000000000..58e74e274 --- /dev/null +++ b/copi.owasp.org/test/copi/session_cleanup_test.exs @@ -0,0 +1,23 @@ +defmodule Copi.SessionCleanupTest do + use ExUnit.Case, async: false + + alias Copi.SessionCleanup + + test "start_link starts and exits cleanly when postgres session storage is disabled" do + previous = Application.get_env(:copi, :postgres_session_store_enabled) + + on_exit(fn -> + if is_nil(previous) do + Application.delete_env(:copi, :postgres_session_store_enabled) + else + Application.put_env(:copi, :postgres_session_store_enabled, previous) + end + end) + + Application.put_env(:copi, :postgres_session_store_enabled, false) + + assert {:ok, pid} = SessionCleanup.start_link([]) + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal} + end +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..4d59655ff 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 @@ -1,5 +1,6 @@ defmodule CopiWeb.ApiControllerTest do use CopiWeb.ConnCase + alias CopiWeb.ApiController alias Copi.Repo alias Copi.Cornucopia alias Copi.Cornucopia.DealtCard @@ -31,7 +32,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 +60,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 +80,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 +114,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 +132,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 +154,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 @@ -166,6 +193,12 @@ defmodule CopiWeb.ApiControllerTest do assert json_response(conn, 400)["error"] == "Missing required parameter: dealt_card_id" end + test "play_card returns 400 when params are missing game_id and player_id", %{conn: conn} do + conn = ApiController.play_card(conn, %{}) + + assert json_response(conn, 400)["error"] == "Invalid request parameters" + end + test "play_card returns 503 when initial game lookup is transient", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do Application.put_env(:copi, :api_game_module, GameStub) Application.put_env(:copi, :api_game_stub_mode, :initial_transient) @@ -226,57 +259,260 @@ 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}) - assert json_response(conn, 400)["error"] == "Invalid player session parameters" - assert get_session(conn, "resume_player_session") == nil + 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 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 + |> 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(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 "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(%{}) - |> 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" => 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}, + %{"game_id" => game.id, "player_id" => other_player.id} + ] }) + |> delete("/api/games/#{game.id}/players/#{player.id}/session") - assert json_response(conn, 403)["error"] == "Player does not belong to this game" - assert get_session(conn, "resume_player_session") == nil + assert json_response(conn, 200)["ok"] == true + 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 - test "clear_player_session removes the stored resume pointer for the matching game", %{conn: conn, game: game, player: player} do + test "clear_player_session deletes the session key when the last player session is removed", %{ + conn: conn, + game: game, + player: player + } do 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}] + }) + |> 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_resp_header(conn, "cache-control") == ["no-store"] end -end \ No newline at end of file + + test "clear_player_session returns 400 for an invalid game id format", %{conn: conn} do + conn = + delete( + conn, + "/api/games/not-a-valid-ulid/players/00000000000000000000000001/session" + ) + + assert json_response(conn, 400)["error"] == "Invalid player session parameters" + assert get_resp_header(conn, "cache-control") == ["no-store"] + end + + test "clear_player_session returns 400 for an invalid player id format", %{conn: conn} do + conn = + delete( + conn, + "/api/games/00000000000000000000000001/players/not-a-valid-ulid/session" + ) + + assert json_response(conn, 400)["error"] == "Invalid player session parameters" + assert get_resp_header(conn, "cache-control") == ["no-store"] + end +end diff --git a/copi.owasp.org/test/copi_web/controllers/player_handoff_controller_test.exs b/copi.owasp.org/test/copi_web/controllers/player_handoff_controller_test.exs new file mode 100644 index 000000000..e98a35255 --- /dev/null +++ b/copi.owasp.org/test/copi_web/controllers/player_handoff_controller_test.exs @@ -0,0 +1,104 @@ +defmodule CopiWeb.PlayerHandoffControllerTest do + use CopiWeb.ConnCase, async: false + + alias Copi.Cornucopia + alias CopiWeb.PlayerHandoff + alias CopiWeb.PlayerSessions + + setup do + {:ok, game} = Cornucopia.create_game(%{name: "Handoff Game"}) + {:ok, player} = Cornucopia.create_player(%{name: "Handoff Player", game_id: game.id}) + + %{game: game, player: player} + end + + test "redeems a handoff into a renewed player session", %{ + conn: conn, + game: game, + player: player + } do + token = PlayerHandoff.sign(game.id, player.id) + conn = get(conn, "/player-handoffs/#{token}") + + assert redirected_to(conn) == "/games/#{game.id}/players/#{player.id}" + + assert PlayerSessions.authorized?( + get_session(conn, "resume_player_session"), + game.id, + player.id + ) + + assert get_resp_header(conn, "cache-control") == ["no-store"] + end + + test "preserves other player sessions when redeeming", %{conn: conn, game: game, player: player} do + {:ok, other_player} = + Cornucopia.create_player(%{name: "Other Player", game_id: game.id}) + + token = PlayerHandoff.sign(game.id, player.id) + + conn = + conn + |> init_test_session(%{ + "resume_player_session" => [ + %{"game_id" => game.id, "player_id" => other_player.id} + ] + }) + |> get("/player-handoffs/#{token}") + + sessions = get_session(conn, "resume_player_session") + assert PlayerSessions.authorized?(sessions, game.id, player.id) + assert PlayerSessions.authorized?(sessions, game.id, other_player.id) + end + + test "rejects a replay without granting a session", %{conn: conn, game: game, player: player} do + token = PlayerHandoff.sign(game.id, player.id) + + assert conn |> get("/player-handoffs/#{token}") |> redirected_to() == + "/games/#{game.id}/players/#{player.id}" + + replay_conn = build_conn() |> get("/player-handoffs/#{token}") + + assert redirected_to(replay_conn) == "/games" + assert Phoenix.Flash.get(replay_conn.assigns.flash, :error) =~ "invalid, expired" + assert get_session(replay_conn, "resume_player_session") == nil + assert get_resp_header(replay_conn, "cache-control") == ["no-store"] + end + + test "rejects a tampered token", %{conn: conn, game: game, player: player} do + token = PlayerHandoff.sign(game.id, player.id) <> "tampered" + conn = get(conn, "/player-handoffs/#{token}") + + assert redirected_to(conn) == "/games" + assert get_session(conn, "resume_player_session") == nil + end + + test "rejects a player capability from another purpose", %{ + conn: conn, + game: game, + player: player + } do + token = CopiWeb.PlayerCapability.sign(game.id, player.id) + conn = get(conn, "/player-handoffs/#{token}") + + assert redirected_to(conn) == "/games" + assert get_session(conn, "resume_player_session") == nil + end + + test "rejects signed malformed identifiers", %{conn: conn} do + token = PlayerHandoff.sign("invalid-game", "invalid-player") + conn = get(conn, "/player-handoffs/#{token}") + + assert redirected_to(conn) == "/games" + assert get_session(conn, "resume_player_session") == nil + end + + test "rejects a signed player and game mismatch", %{conn: conn, player: player} do + {:ok, other_game} = Cornucopia.create_game(%{name: "Other Game"}) + token = PlayerHandoff.sign(other_game.id, player.id) + conn = get(conn, "/player-handoffs/#{token}") + + assert redirected_to(conn) == "/games" + assert get_session(conn, "resume_player_session") == nil + end +end 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..7cb76c345 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}) @@ -396,6 +409,47 @@ defmodule CopiWeb.PlayerLive.ShowTest do assert render(view) =~ "Temporary issue loading player/game" end + test "retry player show load with map params retries after a transient game lookup", %{ + conn: conn + } do + {game, player, _dealt} = + create_game_with_dealt_card("Retry Player Show Map Branch", "RPS_MAP_1") + + Application.put_env(:copi, :player_live_show_player_module, PlayerStub) + Application.put_env(:copi, :player_live_show_game_module, GameStub) + Application.put_env(:copi, :player_live_show_player_stub_mode, :real) + Application.put_env(:copi, :player_live_show_game_stub_mode, :real) + + {:ok, view, _html} = live(authorize_player(conn, game.id, player.id), player_url(game.id, player.id)) + + Application.put_env(:copi, :player_live_show_game_stub_mode, :transient) + send(view.pid, {:retry_player_show_load, %{"game_id" => game.id, "id" => player.id}}) + :timer.sleep(50) + + assert render(view) =~ "Temporary issue loading player/game. Retrying..." + end + + test "redirects when the player record belongs to a different game than the URL", %{ + conn: conn, + player: player + } do + original_game_id = player.game_id + {:ok, other_game} = Cornucopia.create_game(%{name: "Moved Player Game"}) + + player + |> Ecto.Changeset.change(game_id: other_game.id) + |> Copi.Repo.update!() + + expected_path = "/games/#{original_game_id}" + + assert {:error, + {:redirect, + %{ + to: ^expected_path, + flash: %{"error" => "This player link is not available in this browser session."} + }}} = live(conn, "/games/#{original_game_id}/players/#{player.id}") + end + test "toggle_vote hits insert error branch when vote conflicts", %{conn: conn} do {game, player, dealt} = create_game_with_dealt_card("Vote Conflict", "VC_1") @@ -404,7 +458,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 +466,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 +514,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 +524,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 +533,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 +541,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 +571,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 +586,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..3b2de535c 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,9 +225,36 @@ 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] + test "creates a single-use five-minute handoff link", %{conn: conn, player: player} do + {:ok, show_live, html} = live(conn, "/games/#{player.game_id}/players/#{player.id}") + + assert html =~ "Share your hand" + refute html =~ "/player-handoffs/" + + html = show_live |> element(~s{[phx-click="share_hand"]}) |> render_click() + + assert html =~ "This single-use link expires in 5 minutes." + assert html =~ "/player-handoffs/" + assert has_element?(show_live, "#copy-url-btn") + end + test "allows voting on other player's card", %{conn: conn, player: player} do # Setup another player and play a card game_id = player.game_id diff --git a/copi.owasp.org/test/copi_web/player_capability_test.exs b/copi.owasp.org/test/copi_web/player_capability_test.exs new file mode 100644 index 000000000..73b6ed0d1 --- /dev/null +++ b/copi.owasp.org/test/copi_web/player_capability_test.exs @@ -0,0 +1,40 @@ +defmodule CopiWeb.PlayerCapabilityTest do + use ExUnit.Case, async: true + + alias CopiWeb.PlayerCapability + + test "verify/3 returns :ok for a matching token" do + game_id = "01HV000000000000000000001A" + player_id = "01HV000000000000000000002B" + token = PlayerCapability.sign(game_id, player_id) + + assert :ok = PlayerCapability.verify(token, game_id, player_id) + end + + test "verify/3 returns an error for a different game id" do + game_id = "01HV000000000000000000001A" + other_game_id = "01HV000000000000000000003C" + player_id = "01HV000000000000000000002B" + token = PlayerCapability.sign(game_id, player_id) + + assert {:error, :invalid} = PlayerCapability.verify(token, other_game_id, player_id) + end + + test "verify/3 returns an error for a different player id" do + game_id = "01HV000000000000000000001A" + player_id = "01HV000000000000000000002B" + other_player_id = "01HV000000000000000000004D" + token = PlayerCapability.sign(game_id, player_id) + + assert {:error, :invalid} = PlayerCapability.verify(token, game_id, other_player_id) + end + + test "verify/3 returns an error for an invalid token" do + assert {:error, :invalid} = PlayerCapability.verify("bad_token", "game", "player") + end + + test "verify/1 returns an error for non-binary input" do + assert {:error, :invalid} = PlayerCapability.verify(nil) + assert {:error, :invalid} = PlayerCapability.verify(123) + end +end diff --git a/copi.owasp.org/test/copi_web/player_handoff_test.exs b/copi.owasp.org/test/copi_web/player_handoff_test.exs new file mode 100644 index 000000000..dbae12934 --- /dev/null +++ b/copi.owasp.org/test/copi_web/player_handoff_test.exs @@ -0,0 +1,43 @@ +defmodule CopiWeb.PlayerHandoffTest do + use ExUnit.Case, async: true + + alias CopiWeb.PlayerHandoff + + test "round trips a purpose-bound game and player capability" do + game_id = "01HV000000000000000000001A" + player_id = "01HV000000000000000000002B" + + assert {:ok, %{game_id: ^game_id, player_id: ^player_id}} = + game_id + |> PlayerHandoff.sign(player_id) + |> PlayerHandoff.verify() + end + + test "expires after five minutes" do + assert PlayerHandoff.max_age_seconds() == 300 + + expired_token = + PlayerHandoff.sign( + "01HV000000000000000000001A", + "01HV000000000000000000002B", + signed_at: System.system_time(:second) - 301 + ) + + assert {:error, :invalid} = PlayerHandoff.verify(expired_token) + end + + test "rejects invalid and non-binary tokens" do + assert {:error, :invalid} = PlayerHandoff.verify("invalid") + assert {:error, :invalid} = PlayerHandoff.verify(nil) + end + + test "does not accept a player creation capability" do + capability = + CopiWeb.PlayerCapability.sign( + "01HV000000000000000000001A", + "01HV000000000000000000002B" + ) + + assert {:error, :invalid} = PlayerHandoff.verify(capability) + end +end diff --git a/copi.owasp.org/test/copi_web/player_sessions_test.exs b/copi.owasp.org/test/copi_web/player_sessions_test.exs new file mode 100644 index 000000000..1a35897cd --- /dev/null +++ b/copi.owasp.org/test/copi_web/player_sessions_test.exs @@ -0,0 +1,65 @@ +defmodule CopiWeb.PlayerSessionsTest do + use ExUnit.Case, async: true + + alias CopiWeb.PlayerSessions + + test "adds, authorizes, lists, and removes exact game/player pairs" do + sessions = + nil + |> PlayerSessions.add("game-1", "player-1") + |> PlayerSessions.add("game-1", "player-2") + |> PlayerSessions.add("game-2", "player-3") + + assert PlayerSessions.authorized?(sessions, "game-1", "player-1") + assert PlayerSessions.authorized?(sessions, "game-1", "player-2") + refute PlayerSessions.authorized?(sessions, "game-1", "player-3") + refute PlayerSessions.authorized?(sessions, "game-2", "player-1") + + assert PlayerSessions.player_ids_for_game(sessions, "game-1") == ["player-2", "player-1"] + + remaining = PlayerSessions.remove(sessions, "game-1", "player-2") + refute PlayerSessions.authorized?(remaining, "game-1", "player-2") + assert PlayerSessions.authorized?(remaining, "game-1", "player-1") + end + + test "moves a duplicate pair to the front without storing it twice" do + sessions = [ + %{"game_id" => "game-2", "player_id" => "player-2"}, + %{"game_id" => "game-1", "player_id" => "player-1"} + ] + + assert PlayerSessions.add(sessions, "game-1", "player-1") == [ + %{"game_id" => "game-1", "player_id" => "player-1"}, + %{"game_id" => "game-2", "player_id" => "player-2"} + ] + end + + test "accepts the legacy single-entry map and rejects malformed session data" do + legacy = %{"game_id" => "game-1", "player_id" => "player-1"} + + assert PlayerSessions.entries(legacy) == [legacy] + + malformed = [ + legacy, + %{"game_id" => "game-2"}, + %{"game_id" => 2, "player_id" => "player-2"}, + :invalid + ] + + assert PlayerSessions.entries(malformed) == [legacy] + assert PlayerSessions.entries(nil) == [] + assert PlayerSessions.entries(%{}) == [] + end + + test "limits a browser session to the twenty most recently added pairs" do + sessions = + Enum.reduce(1..25, [], fn number, entries -> + PlayerSessions.add(entries, "game-#{number}", "player-#{number}") + end) + + assert length(sessions) == 20 + assert hd(sessions) == %{"game_id" => "game-25", "player_id" => "player-25"} + assert List.last(sessions) == %{"game_id" => "game-6", "player_id" => "player-6"} + refute PlayerSessions.authorized?(sessions, "game-5", "player-5") + end +end 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..86efaaeb6 --- /dev/null +++ b/copi.owasp.org/test/copi_web/session_store_test.exs @@ -0,0 +1,189 @@ +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"} + ] + } + + raw_cookie = SessionStore.put(conn, nil, session, opts) + + assert {session_id, ^session} = SessionStore.get(conn, raw_cookie, opts) + assert :ok = SessionStore.delete(conn, session_id, opts) + 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_cookie = SessionStore.put(conn, nil, first_session, opts) + + refute Repo.get(SessionRecord, "expired-session") + + [record] = Repo.all(SessionRecord) + record_id = record.id + 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_cookie = SessionStore.put(conn, record_id, updated_session, opts) + + 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) + + raw_cookie = + SessionStore.put( + conn, + nil, + %{"resume_player_session" => [%{"game_id" => "game", "player_id" => "player"}]}, + opts + ) + + session_id = postgres_session_id(conn, raw_cookie, opts) + + assert :ok = SessionStore.delete(conn, session_id, opts) + refute Repo.get(SessionRecord, session_id) + 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 + Plug.Session.COOKIE.put(conn, nil, %{"postgres_session_id" => session_id}, opts.cookie) + 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 restore_env(key, nil), do: Application.delete_env(:copi, key) + defp restore_env(key, value), do: Application.put_env(:copi, key, value) +end diff --git a/cornucopia.owasp.org/data/website/pages/copi/en/index.md b/cornucopia.owasp.org/data/website/pages/copi/en/index.md index a477e3719..39b58220a 100644 --- a/cornucopia.owasp.org/data/website/pages/copi/en/index.md +++ b/cornucopia.owasp.org/data/website/pages/copi/en/index.md @@ -16,6 +16,7 @@ cd cornucopia/copi.owasp.org ``` ### Installation by Operating System + #### Mac ##### Get Homebrew @@ -35,6 +36,7 @@ brew install elixir ``` #### Linux and Windows + Follow the installation process for your [Linux distribution](https://elixir-lang.org/install.html#gnulinux) and [Windows](https://elixir-lang.org/install.html#windows). ### Install the Elixir package manager, Hex @@ -56,16 +58,16 @@ mix archive.install hex phx_new ``` ### PostgreSQL with Docker -https://docs.docker.com/desktop/install/mac-install/ + +[docs.docker.com/desktop/install/mac-install/](https://docs.docker.com/desktop/install/mac-install/) After installing docker, You can create an instance of the Postgres image: ```bash openssl rand -base64 32 # please note the output. E.g: 5xpdhzomIy4Di+UFw/r7SJSb7pvQhPitXGoet7fbMCY= # Linux -export COPI_ENCRYPTION_KEY=5xpdhzomIy4Di+UFw/r7SJSb7pvQhPitXGoet7fbMCY= +export COPI_ENCRYPTION_KEY="5xpdhzomIy4Di+UFw/r7SJSb7pvQhPitXGoet7fbMCY=" export POSTGRES_PASSWORD=POSTGRES_LOCAL_PWD - # Windows # $env:COPI_ENCRYPTION_KEY="5xpdhzomIy4Di+UFw/r7SJSb7pvQhPitXGoet7fbMCY=" # $env:POSTGRES_PASSWORD="POSTGRES_LOCAL_PWD" @@ -81,6 +83,7 @@ You've now got Elixir, Hex, Phoenix and Postgres. You are ready to run Copi loca Bonus: set up vscode for elixir dev https://fly.io/phoenix-files/setup-vscode-for-elixir-development/ ### Clone the copi code, then + To start your Phoenix server: * Install dependencies with `mix deps.get` @@ -94,18 +97,34 @@ To start your Phoenix server: docker run --name copi_dev -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=POSTGRES_LOCAL_PWD -d postgres # Linux export POSTGRES_TEST_PWD=POSTGRES_LOCAL_PWD - # Windows # $env:POSTGRES_TEST_PWD="POSTGRES_LOCAL_PWD" - mix test - * +mix assets.coverage ``` 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/ @@ -233,58 +252,70 @@ heroku certs:add cloudflare.crt cloudflare.key The Copi threat model can be found at [ThreatDragonModels/copi.json](https://github.com/OWASP/cornucopia/blob/master/ThreatDragonModels/copi.json). You may review it by using [OWASP Threat Dragon](https://www.threatdragon.com/#/dashboard). +Please also read [SECURITY.md](https://github.com/OWASP/cornucopia/blob/master/copi.owasp.org/SECURITY.md) to ensure you have taken the appropriate measures to secure Copi if you are running the service yourself. + 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 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. -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). +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). -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. +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. -#### What are we going to do about it? +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. -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 get 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 did. The cards themselves are two generic and doesn'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 pseudonyme and a fake threat model name. +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. -### CRA: Data is not encrypted at rest by default +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. -#### What can go wrong? - -When hosting Copi yourself, be aware that the data at REST might not be encrypted. Even if you tell your threat modeling participants not to use their own name or use information about your company or project when creating the game, they may end up doing it by accident or because of a temporary lapse in memory. The data on copi.owasp.org is encrypted at rest, but if you host the game engine yourself, you need to make sure that the data is encrypted at rest as well, since the configuration does not apply encryption by default. If you don't, an attacker that get hold of the database may be able to see the names of the players and games, which may contain sensitive information. Currently, we do both application-side and server-side encryption. Please make sure you properly configure encryption as outlined in [SECURITY.md](https://github.com/OWASP/cornucopia/blob/master/copi.owasp.org/SECURITY.md). +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? -The data at REST on copi.owasp.org is encrypted by default, but we are not using client side encryption. This means that I have the possibility to access the database and see what you entered in your name fields, but I will rather quite my job then be caught snooping on your data. It would be the end of my career if I got caught doing so and the end of Copi if someone else did. When all that is said, we will implement client side encryption on Copi since I wouldn't want anyone to have the possibility to even suspect me of doing such a foolish thing (see: https://github.com/OWASP/cornucopia/issues/2232). -Ensure that your service provider ensures that the data is encrypted at REST. -OWASP host the data on Fly.io. Databases built on Fly.io uses volumes, which provide persistent storage. These drives are block-level encrypted with AES-XTS. Fly.io manages the encryption keys, ensuring they are accessible only to privileged processes running your application instances. New volumes (and thus new Postgres apps) are encrypted by default. +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. -### 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. +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. + +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. #### What can go wrong? +Production database transport security is not safely configured by default. The DB SSL option is set at startup to either false or verify_none, and the app applies that value directly to the Repo. In other words, the current code path never enables verified TLS to Postgres by default. If the database is not strictly local/private, credentials and application data can be exposed or modified in transit. If deploying Copi, configure TLS between the DB and your app and between the nodes in your app cluster. Erlang clustering does not happen over TLS by default. This may allow an attacker to launch a MTM attack and do RCE against your cluster. It may also allow an attacker to take over your database connection and both disclose sensitive information and compromise the integrity of the data sent between your database and Copi. +Cookie and TLS hardening still depend on deployment discipline rather than being enforced by the app. The repo ships a fixed secret_key_base in config.exs, the session cookie is only marked secure during app start-up, and HTTPS enforcement is only documented, not enabled by default. If a staging or self-hosted deployment ever runs outside strict prod/TLS settings, session confidentiality and integrity may degrade quickly. + #### What are we going to do about it? -if you deploy Copi yourself, make sure you configure TLS appropriatly according to your needs. +If you deploy Copi yourself, make sure you configure TLS appropriately according to your needs. OWASP host Copi on Fly.io that uses a built-in, WireGuard-encrypted 6PN (IPv6 Private Networking) mesh to automatically connect all your app instances, providing zero-config, secure, private communication with internal DNS (e.g., app-name.internal), allowing services to talk as if they're on the same network, even across regions, for simple and secure microservices communication. This mesh handles complex routing, making it easy to build distributed apps securely without manual VPN setup. -### AZ: Mike can misuse an application by using a valid feature too fast, or too frequently, or other way that is not intended, or consumes the application's resources, or causes race conditions, or over-utilizes a feature. +### AZ: Mike can misuse an application by using a valid feature too fast, or too frequently, or in any other way that is not intended, or consumes the application's resources, or causes race conditions, or over-utilizes a feature. #### What can go wrong? An attacker can deny access to user's by CAPEC 212, functionality misuse by continuing to create an unlimited amount of games and players until the application stops responding. +The current rate-limiting design is easy to evade in common proxy or multi-instance deployments. The rate limiting trusts the left-most X-Forwarded-For value and explicitly skips connection limiting when only remote_ip is available. The counters are kept in the in-memory GenServer. That means spoofed forwarded headers, multiple app nodes, or proxy/header misconfiguration can let an attacker bypass the main abuse-control mechanism. We are more than happy to get suggestions for how to improve the rate-limiting. Currently we are not seeing a lot of issues concerning the misuse of our services in production. + +The Fly.io reverse proxy does not strip or rewrite untrusted X-Forwarded-For headers before traffic reaches Phoenix. It is therefore still possible to circumvent the rate-limiter. + #### What are we going to do about it? -We are working on minimizing the probability of functionality misue by implementing rate limiting on the creating of games and players (see: [issues/1877](https://github.com/OWASP/cornucopia/issues/1877)). Once that is taken care of, you should be able to configure these limits to prevent DoS attacks when hosting Copi yourself. It's vital that you limit the number of sockets the application accept concurrently. On fly.io that is done in the following way: [fly.toml](https://github.com/OWASP/cornucopia/blob/fb9aae62531dde8db154729d0df4aa28a3400063/copi.owasp.org/fly.toml#L27) A 30 socket limit for Copi should allow you to handle 20.000 requests per min if you have 2 single cpu nodes. +We are working on minimizing the probability of functionality misuse by implementing rate limiting on the creation of games and players (see: [issues/1877](https://github.com/OWASP/cornucopia/issues/1877)). Once that is taken care of, you should be able to configure these limits to prevent DoS attacks when hosting Copi yourself. It's vital that you limit the number of sockets the application accepts concurrently. On fly.io that is done in the following way: [fly.toml](https://github.com/OWASP/cornucopia/blob/fb9aae62531dde8db154729d0df4aa28a3400063/copi.owasp.org/fly.toml#L27) A 30 socket limit for Copi should allow you to handle 20.000 requests per min if you have 2 single cpu nodes Which we have tested against that setup. + +When Copi receives traffic directly from Fly Proxy, set `USE_FLY_CLIENT_IP=true`. [Fly.io documents `Fly-Client-IP`](https://fly.io/docs/networking/request-headers/#fly-client-ip) as the client IP address from Fly Proxy's perspective and says it may be a better choice than `X-Forwarded-For`, which must be treated with caution to avoid spoofing. Using it prevents Copi's rate limiter from trusting a client-controlled, left-most `X-Forwarded-For` value. If another reverse proxy is in front of Fly.io, `Fly-Client-IP` contains that proxy's address instead of the original client's address; in that deployment, parse `X-Forwarded-For` using an explicit trusted-proxy configuration. ### CK: Grant can utilize the application to deny service to some or all of its users @@ -294,10 +325,10 @@ Given that a threat actor can execute a distributed denial of service attack a #### What are we going to do about it? -We are not working towards implementing any specific controls to prevent DoS attacks against copi.owasp.org. Most probably, it would be impossible to stop a distributed denial of service attack if executed properly. When we did load testing against copi.owasp.org, we found that the application could handle 20.000 request per min. If we went higher then that, Cloudflare, that host the DNS, would identify us as a DoS actor and return HTTP status 520. Still, conceptually, you could execute a DoS from one million machines and deny access to the application for other users. Even though this is a risk, we accept it. If you are worried about distributed DoS, please host the application on a private network or IP whitelist access to the application. -If you are hosting Copi yourself please set the rate limiting according to your needs (see: [SECURITY.md](https://github.com/OWASP/cornucopia/blob/master/copi.owasp.org/SECURITY.md)). +We are not working towards implementing any specific controls to prevent DoS attacks against copi.owasp.org. Most probably, it would be impossible to stop a distributed denial of service attack if executed properly. When we did load testing against copi.owasp.org, we found that the application could handle 20.000 request per min. If we went higher than that, Cloudflare, which hosts the DNS, would identify us as a DoS actor and return HTTP status 520. Still, conceptually, you could execute a DoS from one million machines and deny access to the application for other users. Even though this is a risk, we accept it. If you are worried about distributed DoS, please host the application on a private network or whitelist IP access to the application. +If you are hosting Copi yourself, please set the rate limiting according to your needs (see: [Configuration](https://github.com/OWASP/cornucopia/blob/master/copi.owasp.org/SECURITY.md#configuration)). ### Did we do a good job? -We welcome any input or improvments you might be willing to share with us regarding our current threat model. -Arguably, we created the system before we were able to identify all these threats, and several improvements need to be made to properly balance the inherrant risks of compromise against the current security controls. For anyone choosing to host the game engine, please take this into account. +We welcome any input or improvements you might be willing to share with us regarding our current threat model. +Arguably, we created the system before we were able to identify all these threats, and several improvements need to be made to properly balance the inherent risks of compromise against the current security controls. For anyone choosing to host the game engine, please take this into account.