From 9e4061031e982e9c36c960d44b27066dc7363d07 Mon Sep 17 00:00:00 2001 From: Longmao Date: Wed, 10 Jun 2026 08:29:36 +0800 Subject: [PATCH] Fix XSS in chat/killcam/scoreboard + scope quick-chat to the match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player-controlled text (names, chat) was interpolated straight into innerHTML, allowing XSS against other players โ€” including persistent XSS via the localStorage kill log. Quick-chat was also broadcast globally (socket.broadcast.emit) instead of to the match. - Add escapeHtml() + safeColor() helpers to game.js (mirrors chat.js:431) - Escape chat-feed text + validate colour (renderChatFeed) - Escape killcam victim/weapon (openKillTheater) - Escape scoreboard player name (showScoreboard) - Scope chatLine to emitToMatch(p.matchId, ...) in server.js No gameplay changes. node --check passes on both files. Co-Authored-By: Claude Opus 4.8 --- public/game.js | 12 +++++++++--- server.js | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/public/game.js b/public/game.js index 998283b..b44da74 100644 --- a/public/game.js +++ b/public/game.js @@ -2,6 +2,11 @@ const socket = window.location.protocol === 'file:' ? io('http://localhost:3001') : io(); +// ๐Ÿ”’ Safety helpers โ€” never let player-controlled text/colour reach innerHTML raw. +// (mirrors escapeHtml in chat.js:431; used by the chat feed, killcam + scoreboard) +function escapeHtml(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } +function safeColor(c){ const s = String(c == null ? '' : c); return (/^#[0-9a-fA-F]{3,8}$/.test(s) || /^[a-zA-Z]{1,20}$/.test(s)) ? s : '#ffffff'; } + // โ”€โ”€ Weapon definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const WEAPONS = [ { @@ -1774,7 +1779,8 @@ function renderChatFeed() { feed.innerHTML = chatLog.map(line => { const age = (Date.now() - line.t) / 1000; const op = age > 6 ? Math.max(0, 1 - (age - 6) / 2) : 1; - return `
${line.text}
`; + const _col = safeColor(line.color), _txt = escapeHtml(line.text); + return `
${_txt}
`; }).join(''); } function updateChatFeed() { @@ -8952,7 +8958,7 @@ function openKillTheater(index) { ov.style.display = 'block'; ov.innerHTML = `
- ๐Ÿ“น KILL LOG ยท ${replay.victim} ยท ${replay.weapon} + ๐Ÿ“น KILL LOG ยท ${escapeHtml(replay.victim)} ยท ${escapeHtml(replay.weapon)}
@@ -12500,7 +12506,7 @@ function showScoreboard(v) { Object.values(players).sort((a,b)=>b.kills-a.kills).forEach(p => { const tr = document.createElement('tr'); if (p.id===myId) tr.className='me'; - tr.innerHTML=`${p.name}${p.kills}${p.deaths}${p.hp}`; + tr.innerHTML=`${escapeHtml(p.name)}${p.kills}${p.deaths}${p.hp}`; tbody.appendChild(tr); }); } diff --git a/server.js b/server.js index 665ca8a..1d081e2 100644 --- a/server.js +++ b/server.js @@ -1016,7 +1016,7 @@ io.on('connection', (socket) => { const now = Date.now(); if (p._lastChatAt && now - p._lastChatAt < 800) return; // throttle to ~1/0.8s p._lastChatAt = now; - socket.broadcast.emit('chatLine', { + emitToMatch(p.matchId, 'chatLine', { id: socket.id, text: String(data.text || '').slice(0, 60), color: String(data.color || '#fff').slice(0, 12),