bug(server): replay upload uses client-supplied recorded_at for leaderboard — allows timestamp spoofing #74

Closed
opened 2026-05-28 21:36:22 +00:00 by funman300 · 1 comment
Owner

Bug

In solitaire_server/src/replays.rs (lines 159–181), when a winning replay is uploaded and the mode is "Classic", the leaderboard row's recorded_at column is updated from header.recorded_at — a value supplied by the client:

sqlx::query!(
    r#"UPDATE leaderboard
       SET best_score     = ?,
           best_time_secs = ?,
           recorded_at    = ?   -- ← header.recorded_at (client-controlled!)
       ..."
    header.final_score,
    header.time_seconds,
    header.recorded_at,   // ← client-supplied date
    ...

Impact

A client can supply any arbitrary recorded_at string (past or future) and it will be stored verbatim in the leaderboard. Players could backdate scores to appear longer-standing, or future-date them.

Fix

Use the server-computed received_at timestamp (already available in the handler) instead of the client-supplied header.recorded_at when updating the leaderboard row.

## Bug In `solitaire_server/src/replays.rs` (lines 159–181), when a winning replay is uploaded and the mode is `"Classic"`, the leaderboard row's `recorded_at` column is updated from `header.recorded_at` — a value supplied by the client: ```rust sqlx::query!( r#"UPDATE leaderboard SET best_score = ?, best_time_secs = ?, recorded_at = ? -- ← header.recorded_at (client-controlled!) ..." header.final_score, header.time_seconds, header.recorded_at, // ← client-supplied date ... ``` ## Impact A client can supply any arbitrary `recorded_at` string (past or future) and it will be stored verbatim in the leaderboard. Players could backdate scores to appear longer-standing, or future-date them. ## Fix Use the server-computed `received_at` timestamp (already available in the handler) instead of the client-supplied `header.recorded_at` when updating the leaderboard row.
Author
Owner

Fix (commit 7eb1181)

The leaderboard recorded_at update in replays.rs now uses the server-computed received_at timestamp instead of the client-supplied header.recorded_at:

// Before — client could supply any timestamp:
sqlx::query!(
    r#"UPDATE leaderboard SET ... recorded_at = ? ..."
    ...
    header.recorded_at,  // ← client-controlled
    ...
// After — server-minted timestamp only:
sqlx::query!(
    r#"UPDATE leaderboard SET ... recorded_at = ? ..."
    ...
    received_at,  // ← computed by the server at upload time
    ...

received_at is already calculated at the top of the upload handler via Utc::now().to_rfc3339() and stored in the replays table, so no additional database calls are needed. Clients can no longer backdate or future-date their leaderboard entries by supplying a crafted recorded_at in the replay JSON.

## Fix (commit `7eb1181`) The leaderboard `recorded_at` update in `replays.rs` now uses the server-computed `received_at` timestamp instead of the client-supplied `header.recorded_at`: ```rust // Before — client could supply any timestamp: sqlx::query!( r#"UPDATE leaderboard SET ... recorded_at = ? ..." ... header.recorded_at, // ← client-controlled ... ``` ```rust // After — server-minted timestamp only: sqlx::query!( r#"UPDATE leaderboard SET ... recorded_at = ? ..." ... received_at, // ← computed by the server at upload time ... ``` `received_at` is already calculated at the top of the `upload` handler via `Utc::now().to_rfc3339()` and stored in the `replays` table, so no additional database calls are needed. Clients can no longer backdate or future-date their leaderboard entries by supplying a crafted `recorded_at` in the replay JSON.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: funman300/Ferrous-Solitaire#74