fix(server): accept nil user_id placeholder in push; use received_at for leaderboard (#73, #74)
Build and Deploy / build-and-push (push) Successful in 3m37s

- 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>
This commit is contained in:
funman300
2026-05-28 14:41:02 -07:00
parent f444378184
commit 7eb1181e50
2 changed files with 16 additions and 4 deletions
+3 -1
View File
@@ -156,6 +156,8 @@ pub async fn upload(
// Update leaderboard best score/time for opted-in users when this replay // Update leaderboard best score/time for opted-in users when this replay
// beats their existing best. Only classic mode counts for the leaderboard. // 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" { if header.mode == "Classic" {
sqlx::query!( sqlx::query!(
r#"UPDATE leaderboard r#"UPDATE leaderboard
@@ -170,7 +172,7 @@ pub async fn upload(
)"#, )"#,
header.final_score, header.final_score,
header.time_seconds, header.time_seconds,
header.recorded_at, received_at,
user.user_id, user.user_id,
header.final_score, header.final_score,
header.final_score, header.final_score,
+13 -3
View File
@@ -6,6 +6,7 @@
use axum::{Json, extract::State}; use axum::{Json, extract::State};
use chrono::Utc; use chrono::Utc;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use uuid::Uuid;
use solitaire_sync::{ use solitaire_sync::{
AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge,
@@ -142,10 +143,19 @@ pub async fn pull(
pub async fn push( pub async fn push(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(client_payload): Json<SyncPayload>, Json(mut client_payload): Json<SyncPayload>,
) -> Result<Json<SyncResponse>, AppError> { ) -> Result<Json<SyncResponse>, AppError> {
// Reject payloads that claim to belong to a different user. let user_uuid: Uuid = user
if client_payload.user_id.to_string() != user.user_id { .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())); return Err(AppError::BadRequest("user_id mismatch".into()));
} }