From 7eb1181e50a650c846b5a7e620ad490594442d63 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 28 May 2026 14:41:02 -0700 Subject: [PATCH] fix(server): accept nil user_id placeholder in push; use received_at for leaderboard (#73, #74) - sync.rs: replace Uuid::nil() placeholder with the authenticated user's real UUID before the mismatch check so desktop client pushes no longer fail with 400 user_id mismatch (#73) - replays.rs: use server-computed received_at instead of client-supplied header.recorded_at when updating leaderboard recorded_at to prevent timestamp spoofing (#74) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- solitaire_server/src/replays.rs | 4 +++- solitaire_server/src/sync.rs | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/solitaire_server/src/replays.rs b/solitaire_server/src/replays.rs index 8226354..6769962 100644 --- a/solitaire_server/src/replays.rs +++ b/solitaire_server/src/replays.rs @@ -156,6 +156,8 @@ pub async fn upload( // Update leaderboard best score/time for opted-in users when this replay // beats their existing best. Only classic mode counts for the leaderboard. + // Use `received_at` (server-computed) rather than `header.recorded_at` + // (client-supplied) so clients cannot spoof the timestamp. if header.mode == "Classic" { sqlx::query!( r#"UPDATE leaderboard @@ -170,7 +172,7 @@ pub async fn upload( )"#, header.final_score, header.time_seconds, - header.recorded_at, + received_at, user.user_id, header.final_score, header.final_score, diff --git a/solitaire_server/src/sync.rs b/solitaire_server/src/sync.rs index 2a74ad4..3734eb3 100644 --- a/solitaire_server/src/sync.rs +++ b/solitaire_server/src/sync.rs @@ -6,6 +6,7 @@ use axum::{Json, extract::State}; use chrono::Utc; use sqlx::SqlitePool; +use uuid::Uuid; use solitaire_sync::{ AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge, @@ -142,10 +143,19 @@ pub async fn pull( pub async fn push( State(state): State, user: AuthenticatedUser, - Json(client_payload): Json, + Json(mut client_payload): Json, ) -> Result, AppError> { - // Reject payloads that claim to belong to a different user. - if client_payload.user_id.to_string() != user.user_id { + let user_uuid: Uuid = user + .user_id + .parse() + .map_err(|_| AppError::Internal("invalid user_id UUID in JWT".into()))?; + + // The desktop client always sends Uuid::nil() as a placeholder for the + // authenticated user's real ID (see build_payload docstring). Replace it + // here. Reject payloads that explicitly claim a different user's identity. + if client_payload.user_id == Uuid::nil() { + client_payload.user_id = user_uuid; + } else if client_payload.user_id != user_uuid { return Err(AppError::BadRequest("user_id mismatch".into())); }