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:
+12
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
</main>
|
||||
<script>
|
||||
const TOKEN_KEY = 'fs_token';
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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('');
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
</table>
|
||||
</main>
|
||||
<script>
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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 ▶</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 ▶</a></td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('table').style.display = 'table';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user