Skip to content

Security: prevent winner stats spoofing and auth-gate singleplayer archive#4004

Open
Radomir-Aksenenko wants to merge 11 commits into
openfrontio:mainfrom
Radomir-Aksenenko:fix/winner-stats-auth-upstream
Open

Security: prevent winner stats spoofing and auth-gate singleplayer archive#4004
Radomir-Aksenenko wants to merge 11 commits into
openfrontio:mainfrom
Radomir-Aksenenko:fix/winner-stats-auth-upstream

Conversation

@Radomir-Aksenenko
Copy link
Copy Markdown

@Radomir-Aksenenko Radomir-Aksenenko commented May 25, 2026

Description:

Fixes a vulnerability where a malicious client could send a crafted winner WebSocket message with fake allPlayersStats, causing fabricated per-player statistics to be stored in game records.

  • allPlayersStats is now optional in ClientSendWinnerSchema. It is stripped from multiplayer winner messages (the server ignores client-reported stats) but still sent for singleplayer, where LocalServer needs it for offline archiving.
  • GameServer.archiveGame() no longer trusts client stats — it archives stats: undefined for all players. A server-side stats tracker is still TODO (see code comment); multiplayer per-player stats are not recorded for now.
  • POST /api/archive_singleplayer_game now requires a JWT Bearer token and verifies the persistentID matches the token owner. Anonymous singleplayer games are no longer archived (intentional).
  • LocalServer.endGame() attaches Authorization: Bearer <token> when archiving singleplayer games, and skips the upload entirely if the user is logged out.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

radomir3434

Radomir-Aksenenko and others added 4 commits May 25, 2026 17:56
Two attack vectors closed:

1. allPlayersStats in ClientSendWinnerMessage was fully client-controlled,
   allowing a modified client (PoC browser extension confirmed) to archive
   arbitrary stats for any player account in multiplayer games.
   Fix: GameServer now ignores allPlayersStats entirely; Transport no longer
   sends it in multiplayer winner messages; the field is marked optional in
   the schema so LocalServer (offline singleplayer) continues to work.

2. POST /api/archive_singleplayer_game accepted game records with any
   persistentID without authentication, allowing unauthenticated clients
   to submit fake records for other players' accounts.
   Fix: endpoint now requires a valid JWT (Bearer token); the persistentID
   in the record must match the token owner.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
LocalServer.endGame() now attaches the Authorization: Bearer token to
the POST /api/archive_singleplayer_game request, which the endpoint now
requires after the security hardening in the previous commit.
If the user is not logged in the header is omitted gracefully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify that the winner message schema accepts/rejects correct inputs
and that allPlayersStats is optional (not required on the wire).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Per-player stats are optional and dropped from multiplayer winner messages; server stores a normalized Winner and no longer copies client-supplied stats into archived player records. Singleplayer archive uploads use gzipped JSON and require a matching bearer JWT.

Changes

Winner Messaging and Archive Security

Layer / File(s) Summary
Winner Message Schema and Type Definitions
src/core/Schemas.ts, src/server/GameServer.ts
ClientSendWinnerSchema marks allPlayersStats optional; GameServer imports Winner, changes winner to `Winner
Multiplayer Winner Message Flow
src/client/Transport.ts, src/client/LocalServer.ts
Transport.onSendWinnerEvent omits allPlayersStats for networked messages; LocalServer defaults missing allPlayersStats to {} for local handling.
Client-Side Archive Upload and Gzip Flow
src/client/LocalServer.ts
Import getAuthHeader; singleplayer archive POSTs gzip the validated record and attach Authorization when available; return early (skip POST) when no token; errors are caught and logged.
Server Authorization and Ownership Validation
src/server/Worker.ts
/api/archive_singleplayer_game requires Authorization: Bearer ...; invalid/missing tokens return 401; token persistentId must match the game record persistentID, otherwise 403.
Winner Data Archiving
src/server/GameServer.ts
archiveGame() no longer derives PlayerRecord.stats from winner messages (sets it to undefined); archived winner passed as this.winner ?? undefined.
Schema and Security Tests
tests/ClientSendWinnerSchema.test.ts, tests/server/WinnerSecurity.test.ts
Jest tests validate winner schema parsing with/without allPlayersStats; Vitest verifies archived records do not include player stats from client-supplied data.

Sequence Diagram

sequenceDiagram
  participant Client
  participant LocalServer
  participant ArchiveEndpoint
  participant TokenVerifier
  participant GameRecord
  Client->>LocalServer: send winner (no allPlayersStats over network)
  LocalServer->>ArchiveEndpoint: POST gzipped game (Authorization: Bearer token)
  ArchiveEndpoint->>TokenVerifier: verifyClientToken(token)
  TokenVerifier-->>ArchiveEndpoint: { persistentId } or Error
  ArchiveEndpoint->>GameRecord: compare persistentId with game's singleplayer persistentID
  alt Owner Match
    ArchiveEndpoint->>GameRecord: store archive (player.stats = undefined)
  else Owner Mismatch
    ArchiveEndpoint-->>Client: 403 Forbidden
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • evanpelle

Poem

🏁 Winner notes trimmed, the network stays light,
Gzip rolls up records, headers set just right.
Tokens check owners before the vault is stored,
Server keeps stats quiet — client claims ignored.
Tests nod, the new flow passes through the night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main security improvements: preventing winner stats spoofing and adding authentication for singleplayer archive endpoints.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description clearly explains the vulnerability being fixed, the changes made across multiple files, and how the security issue is resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/client/LocalServer.ts (1)

306-317: ⚡ Quick win

Decouple optional auth lookup from archive upload.

Promise.all makes archive upload fail when getAuthHeader() fails, even though auth is optional in this client path. Prefer a non-blocking auth lookup fallback.

Suggested change
-    Promise.all([compress(jsonString), getAuthHeader()])
-      .then(([compressedData, authHeader]) => {
+    Promise.all([
+      compress(jsonString),
+      getAuthHeader().catch(() => ""),
+    ])
+      .then(([compressedData, authHeader]) => {
         const headers: HeadersInit = {
           "Content-Type": "application/json",
           "Content-Encoding": "gzip",
         };
         if (authHeader) {
           headers["Authorization"] = authHeader;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/LocalServer.ts` around lines 306 - 317, The current
Promise.all([compress(jsonString), getAuthHeader()]) causes the entire archive
upload to fail if getAuthHeader() rejects; instead decouple auth lookup from the
upload by awaiting or resolving compress(jsonString) first and then performing
getAuthHeader() in a non-blocking way (e.g., call getAuthHeader().catch(() =>
undefined) or use Promise.allSettled and ignore a failed auth), then build
headers (including Authorization only if authHeader is truthy) and call fetch to
POST to `/${workerPath}/api/archive_singleplayer_game`; update the code around
compress, getAuthHeader, headers, and the fetch invocation accordingly so a
failed auth lookup does not abort the upload.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/client/LocalServer.ts`:
- Around line 306-317: The current Promise.all([compress(jsonString),
getAuthHeader()]) causes the entire archive upload to fail if getAuthHeader()
rejects; instead decouple auth lookup from the upload by awaiting or resolving
compress(jsonString) first and then performing getAuthHeader() in a non-blocking
way (e.g., call getAuthHeader().catch(() => undefined) or use Promise.allSettled
and ignore a failed auth), then build headers (including Authorization only if
authHeader is truthy) and call fetch to POST to
`/${workerPath}/api/archive_singleplayer_game`; update the code around compress,
getAuthHeader, headers, and the fetch invocation accordingly so a failed auth
lookup does not abort the upload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ef96fc9b-9d17-435a-91f0-69570b1ce448

📥 Commits

Reviewing files that changed from the base of the PR and between 2b45813 and 2096ab7.

📒 Files selected for processing (6)
  • src/client/LocalServer.ts
  • src/client/Transport.ts
  • src/core/Schemas.ts
  • src/server/GameServer.ts
  • src/server/Worker.ts
  • tests/ClientSendWinnerSchema.test.ts

coderabbitai[bot]
coderabbitai Bot previously approved these changes May 25, 2026
If getAuthHeader() rejects (e.g. token refresh network error), the
upload must still proceed rather than being silently aborted.
Compress first, then resolve auth with .catch(() => "") so a failed
auth only results in an unauthenticated request, not a dropped upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously approved these changes May 25, 2026
@iiamlewis
Copy link
Copy Markdown
Contributor

@Celant Can you review please

Copy link
Copy Markdown
Member

@Celant Celant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into this — the actual spoofing fix is right, handleWinner only keeping clientMsg.winner is exactly what we want, and the persistentID/JWT match checks out (sub gets transformed to uuid form on both sides, so logged-in archiving lines up). But there are a couple of things I need sorted before this can go in.

Singleplayer stats are getting dropped. onSendWinnerEvent strips allPlayersStats off every winner message, but that same sendMsg is what feeds LocalServer.onMessage for offline games — and clientMsg.allPlayersStats is the only place LocalServer ever gets stats from. So this.allPlayersStats ends up {} and endGame() archives stats: undefined. We just auth-gated the singleplayer archive specifically to store the player's stats, and now there's nothing to store. The comments saying "LocalServer handles stats separately" aren't true as written. Easiest fix is to only strip it on the multiplayer branch and keep it when isLocal.

Logged-out singleplayer won't archive anymore. Anonymous users have no JWT, so getAuthHeader() returns "" and they just get a 401. If we're fine cutting anonymous archiving, say so explicitly — but I don't think that's the intent. Worth a look.

Description doesn't match the code. It says archiveGame "reads stats from the server-side map populated during simulation," but it actually just sets stats: undefined with a TODO for a future tracker. Which is a reasonable trade-off, but it means multiplayer records lose per-player stats entirely for now — please fix the wording so nobody merges this thinking stats are preserved, and link an issue for the server-side tracker.

Minor: the tests only cover schema parsing, none of the security behavior (server ignoring client stats, the 401, the 403). Would be nice to have at least one of those covered given what this PR is. And the "allPlayersStats is stripped/ignored" test isn't really stripping anything, it's just absent — rename.

The singleplayer stats one is the blocker. Rest is cleanup + a question.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management May 31, 2026
Radomir-Aksenenko and others added 2 commits May 31, 2026 20:31
- LocalServer: skip archive silently when no JWT instead of sending a
  request that would be rejected with 401. Anonymous singleplayer
  archiving is intentionally not supported — the endpoint requires auth
  to prevent spoofing.
- ClientSendWinnerSchema.test: rename misleading "stripped/ignored" test
  to clarify the field is simply absent when not provided by the sender.
- tests/server/WinnerSecurity: new test verifying that the server
  archives stats: undefined regardless of client-supplied allPlayersStats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous commit stripped allPlayersStats from all winner messages,
but LocalServer.endGame() relies on it as the sole source of per-player
stats for singleplayer archiving. Only strip it on the multiplayer path
(socket open); preserve it when isLocal so archived singleplayer records
retain the player's stats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/server/WinnerSecurity.test.ts (1)

1-130: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix Prettier violations in this file to unblock CI.

This file is currently failing the Prettier check in CI; please format it (for example, npx prettier --write tests/server/WinnerSecurity.test.ts).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/WinnerSecurity.test.ts` around lines 1 - 130, This test file
fails Prettier; reformat tests/server/WinnerSecurity.test.ts to match the
project's Prettier config (e.g., run npx prettier --write
tests/server/WinnerSecurity.test.ts or apply your editor's auto-format) so the
code around makeMockWs, makeClient and the "GameServer - winner message
security" test case is consistently styled; commit the formatted file to resolve
the CI Prettier check.
🧹 Nitpick comments (1)
tests/server/WinnerSecurity.test.ts (1)

85-96: ⚡ Quick win

Use the shared setup() helper instead of manual GameServer/Client wiring.

This test builds game/server clients directly; for tests/**/*.test.ts, the suite should use setup() from tests/util/Setup.ts to keep test harness behavior consistent.

As per coding guidelines "tests/**/*.test.ts: Use the setup() helper from tests/util/Setup.ts to create test game instances and exercise core simulation directly".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/WinnerSecurity.test.ts` around lines 85 - 96, Replace the manual
GameServer and client wiring in WinnerSecurity.test.ts with the shared setup()
helper from tests/util/Setup.ts: remove new GameServer(...) and makeClient(...)
calls and instead call setup(...) to obtain the test game instance and client/ws
pairs, then use the returned game object and clients (and start/advance the
simulation per existing tests) so the test uses the standardized harness;
specifically update the section creating GameServer, c1/c2 and ws1/ws2 to use
setup() and its returned game/client/ws values and drop direct
game.joinClient/start calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/server/WinnerSecurity.test.ts`:
- Around line 79-82: The test enables fake timers with vi.useFakeTimers() in the
beforeEach but the afterEach only restores mocks and clears timers; update the
teardown to call vi.useRealTimers() (add vi.useRealTimers() to afterEach
alongside vi.restoreAllMocks() and vi.clearAllTimers()) to avoid leaking
fake-timer mode into later tests, and ensure the test uses the common test setup
helper by calling setup() from the shared test helper (the setup() function in
tests/util/Setup.ts) in the beforeEach so the test follows the
tests/**/*.test.ts guideline.

---

Outside diff comments:
In `@tests/server/WinnerSecurity.test.ts`:
- Around line 1-130: This test file fails Prettier; reformat
tests/server/WinnerSecurity.test.ts to match the project's Prettier config
(e.g., run npx prettier --write tests/server/WinnerSecurity.test.ts or apply
your editor's auto-format) so the code around makeMockWs, makeClient and the
"GameServer - winner message security" test case is consistently styled; commit
the formatted file to resolve the CI Prettier check.

---

Nitpick comments:
In `@tests/server/WinnerSecurity.test.ts`:
- Around line 85-96: Replace the manual GameServer and client wiring in
WinnerSecurity.test.ts with the shared setup() helper from tests/util/Setup.ts:
remove new GameServer(...) and makeClient(...) calls and instead call setup(...)
to obtain the test game instance and client/ws pairs, then use the returned game
object and clients (and start/advance the simulation per existing tests) so the
test uses the standardized harness; specifically update the section creating
GameServer, c1/c2 and ws1/ws2 to use setup() and its returned game/client/ws
values and drop direct game.joinClient/start calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19dc48e7-a7d3-407f-a43a-cf0486e427d4

📥 Commits

Reviewing files that changed from the base of the PR and between 1dcf681 and 00a6195.

📒 Files selected for processing (3)
  • src/client/LocalServer.ts
  • tests/ClientSendWinnerSchema.test.ts
  • tests/server/WinnerSecurity.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/ClientSendWinnerSchema.test.ts

Comment thread tests/server/WinnerSecurity.test.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Radomir-Aksenenko
Copy link
Copy Markdown
Author

@Celant Ready

Copy link
Copy Markdown
Member

@Celant Celant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed — the stats fix and the anonymous-archive handling both look right now, and I like that there's a test actually asserting the fabricated stats get dropped. Two small things and then I'm happy to approve.

CodeRabbit's timer point is fair: beforeEach flips on vi.useFakeTimers() but afterEach only calls restoreAllMocks() and clearAllTimers(), neither of which turns fake timers back off. File isolation means it's not breaking anything today, but it'll bite the moment someone adds a second test here. Just add vi.useRealTimers() to the teardown.

(Ignore its other suggestion about routing this through setup() — that helper is for core-sim tests that need a real Game from map data. This is a server-side GameServer test, setup() doesn't fit.)

Other thing: the description still doesn't match the code. It says allPlayersStats "no longer exists on the wire" (it's optional now and still sent for singleplayer) and that archiveGame "reads stats from the server-side map populated during simulation" (it sets stats: undefined — the server-side tracker is still TODO, as your own comment says). Can you fix those two bullets so nobody reads this later and thinks multiplayer stats are still being recorded?

Fix the timer teardown and tidy the description and I'll approve.

@Celant Celant added this to the v32 milestone May 31, 2026
afterEach cleared timers but left fake timers enabled, which would leak
into any future test added to this file. Add vi.useRealTimers().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Radomir-Aksenenko
Copy link
Copy Markdown
Author

@Celant Ready

Comment thread src/client/LocalServer.ts
.then((compressedData) => {
getAuthHeader()
.then(async (authHeader) => {
// Anonymous users have no JWT. The archive endpoint requires auth to
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anonymous users do have jwts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

4 participants