fix(server): XSS, missing score submission, leaderboard never updated, no LIMIT

- leaderboard.html, replays.html: escape user-supplied display_name and
  username before inserting into innerHTML to prevent stored XSS
- game.js: call POST /api/replays on win so browser-game completions are
  recorded; scores were never submitted before this fix
- replays.rs: after replay insert, upsert leaderboard best_score /
  best_time_secs for opted-in users when the new score beats their current
  best (classic mode only); scores were never updated before this fix
- leaderboard.rs: add LIMIT 100 to GET /api/leaderboard to prevent
  unbounded query growth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-13 19:32:14 -07:00
parent a579c25d5c
commit 38eefb22e8
7 changed files with 77 additions and 8 deletions
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE leaderboard\n SET best_score = ?,\n best_time_secs = ?,\n recorded_at = ?\n WHERE user_id = ?\n AND (\n best_score IS NULL\n OR ? > best_score\n OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "0e199cafab7e71b0c7f28ede85a622e38649d2fe5a73a5c715f2319f5450f729"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC\n LIMIT 100",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112"
"hash": "2b814989a6632ca930ae1e895f97a7fc3389c91d1d2abf6900a21fb0d6e94ef3"
}
+2 -1
View File
@@ -54,7 +54,8 @@ pub async fn get_leaderboard(
CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,
l.best_score DESC,
CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
l.best_time_secs ASC"#
l.best_time_secs ASC
LIMIT 100"#
)
.fetch_all(&state.pool)
.await?;
+26
View File
@@ -114,6 +114,32 @@ pub async fn upload(
.execute(&state.pool)
.await?;
// Update leaderboard best score/time for opted-in users when this replay
// beats their existing best. Only classic mode counts for the leaderboard.
if header.mode == "classic" {
sqlx::query!(
r#"UPDATE leaderboard
SET best_score = ?,
best_time_secs = ?,
recorded_at = ?
WHERE user_id = ?
AND (
best_score IS NULL
OR ? > best_score
OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))
)"#,
header.final_score,
header.time_seconds,
header.recorded_at,
user.user_id,
header.final_score,
header.final_score,
header.time_seconds,
)
.execute(&state.pool)
.await?;
}
Ok(Json(ReplayUploadResponse { id }))
}
+24
View File
@@ -320,6 +320,30 @@ function showWin(s) {
const sec = elapsedSecs % 60;
if (winTime) winTime.textContent = `${m}:${sec.toString().padStart(2, "0")}`;
winOverlay.classList.remove("hidden");
submitReplay(s);
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token) return;
const payload = {
schema_version: 1,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "draw_three" : "draw_one",
mode: "classic",
time_seconds: elapsedSecs,
final_score: s.score,
move_count: s.move_count,
recorded_at: new Date().toISOString(),
moves: [],
};
try {
await fetch('/api/replays', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(payload),
});
} catch (_) { /* best-effort — never block the win screen */ }
}
// ── Auto-complete ─────────────────────────────────────────────────────────────
+4 -1
View File
@@ -100,6 +100,9 @@
</main>
<script>
const TOKEN_KEY = 'fs_token';
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtTime(secs) {
if (!secs) return '—';
const m = Math.floor(secs / 60), s = secs % 60;
@@ -123,7 +126,7 @@
tbody.innerHTML = rows.map((r, i) => `
<tr>
<td class="rank rank-${i+1}">${i+1}</td>
<td>${r.display_name ?? '—'}</td>
<td>${esc(r.display_name) || '—'}</td>
<td class="score">${r.best_score?.toLocaleString() ?? '—'}</td>
<td class="time">${fmtTime(r.best_time_secs)}</td>
</tr>`).join('');
+7 -4
View File
@@ -86,6 +86,9 @@
</table>
</main>
<script>
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtTime(secs) {
if (!secs) return '—';
const m = Math.floor(secs / 60), s = secs % 60;
@@ -111,13 +114,13 @@
status.style.display = 'none';
const tbody = document.getElementById('tbody');
tbody.innerHTML = rows.map(r => `
<tr onclick="location.href='/replays/${r.id}'">
<td class="player">${r.username ?? '—'}</td>
<tr onclick="location.href='/replays/${esc(r.id)}'">
<td class="player">${esc(r.username) || '—'}</td>
<td class="score">${r.final_score?.toLocaleString() ?? '—'}</td>
<td class="time">${fmtTime(r.time_seconds)}</td>
<td class="meta">${r.seed ?? '—'}</td>
<td><span class="draw-badge">Draw ${r.draw_mode ?? '1'}</span></td>
<td><a class="watch-link" href="/replays/${r.id}">Watch &#9654;</a></td>
<td><span class="draw-badge">Draw ${r.draw_mode === 'draw_three' ? '3' : '1'}</span></td>
<td><a class="watch-link" href="/replays/${esc(r.id)}">Watch &#9654;</a></td>
</tr>`).join('');
document.getElementById('table').style.display = 'table';
}