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
+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';
}