Games sync hardening (Codex audit): server-side state normalization
Don't trust client JSON at the storage layer: - sanitize_game_state() runs before merge AND on the merged result (heals legacy rows). Word Search: keep only finds whose cells actually spell a real word in that day's grid (validated when the puzzle exists, shape-only 4-12 alpha + cell-length otherwise), dedupe, renumber ci. Word: validate status enum, guess count/length/alpha, colour-row shape, terminal answer/why. - Completion is now derived from the real puzzle word count (foundWords == expected), not a client-sent `ms` — so stats can't be inflated by junk. - Date validated as YYYY-MM-DD at the API (400 otherwise) — no junk/future rows. Tests: sanitizer-rejects-junk + bad-date 400; existing tests updated to use real-shaped data (the sanitizer is a good forcing function). 237 pytest + 11 vitest green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+9
-2
@@ -1579,27 +1579,34 @@ def create_app() -> FastAPI:
|
||||
return (game == "word" and variant in games.WORD_VARIANTS) or \
|
||||
(game == "wordsearch" and variant in games.WS_TIERS)
|
||||
|
||||
def _valid_pdate(d: str) -> bool:
|
||||
return bool(re.match(r"^\d{4}-\d{2}-\d{2}$", d or "")) # plain YYYY-MM-DD, no junk rows
|
||||
|
||||
@app.get("/api/games/state")
|
||||
def game_state_get(game: str, variant: str, date: str, request: Request) -> dict:
|
||||
if not _game_ok(game, variant):
|
||||
raise HTTPException(status_code=404, detail="no such game")
|
||||
if not _valid_pdate(date):
|
||||
raise HTTPException(status_code=400, detail="bad date")
|
||||
with get_conn() as conn:
|
||||
user = _current_user(conn, request)
|
||||
if not user:
|
||||
return {"state": None}
|
||||
return {"state": games.load_game_state(conn, user["id"], game, variant, date[:10])}
|
||||
return {"state": games.load_game_state(conn, user["id"], game, variant, date)}
|
||||
|
||||
@app.put("/api/games/state")
|
||||
def game_state_put(body: GameStateBody, request: Request) -> dict:
|
||||
if not _game_ok(body.game, body.variant):
|
||||
raise HTTPException(status_code=404, detail="no such game")
|
||||
if not _valid_pdate(body.date):
|
||||
raise HTTPException(status_code=400, detail="bad date")
|
||||
if len(json.dumps(body.state)) > 20000: # a real game state is tiny — reject junk
|
||||
raise HTTPException(status_code=413, detail="state too large")
|
||||
with get_conn() as conn:
|
||||
user = _current_user(conn, request)
|
||||
if not user:
|
||||
return {"state": body.state} # signed out → no sync, just echo
|
||||
merged = games.save_game_state(conn, user["id"], body.game, body.variant, body.date[:10], body.state or {})
|
||||
merged = games.save_game_state(conn, user["id"], body.game, body.variant, body.date, body.state or {})
|
||||
return {"state": merged}
|
||||
|
||||
@app.get("/api/games/stats")
|
||||
|
||||
Reference in New Issue
Block a user