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())); }