security: escape XSS sinks client-side and add CSP + security headers

The client trusted the server to pre-escape chat content and rendered
user-supplied fields via .html()/innerHTML — one server regression away from
stored XSS, and the admin panel injected user-controlled payout addresses
into its authenticated DOM.

- Add a single shared escapeHtml helper (escapes all five HTML-significant
  chars including both quotes); leaderboard now delegates to it.
- Escape chat username + message in onChatBroadcast/onChatHistory.
- Escape user-controlled fields in the admin panel (payout address, socket
  id, player name, status) and make its local escapeHtml quote-safe so values
  in title="..." can't break out.
- Add CSP plus X-Content-Type-Options, X-Frame-Options and Referrer-Policy
  headers via middleware. connect-src 'self' keeps Socket.IO working;
  removing 'unsafe-inline' (nonces) is tracked as a later cleanup.

Full suite: 13 suites / 119 tests.
This commit is contained in:
jwinterm
2026-05-31 09:40:34 -04:00
parent 5c546a1473
commit a664a8bbce
7 changed files with 71 additions and 17 deletions
+16 -10
View File
@@ -738,9 +738,9 @@
<tr>
<td>${p.id}</td>
<td>${(p.amount / atomicDivisor).toFixed(6)}</td>
<td title="${p.address || ''}">${p.address ? p.address.substring(0, 12) + '...' : '-'}</td>
<td><span class="status-badge status-${p.status}">${p.status}</span></td>
<td class="tx-hash">${p.txHash ? p.txHash.substring(0, 16) + '...' : '-'}</td>
<td title="${escapeHtml(p.address || '')}">${p.address ? escapeHtml(p.address.substring(0, 12)) + '...' : '-'}</td>
<td><span class="status-badge status-${escapeHtml(p.status)}">${escapeHtml(p.status)}</span></td>
<td class="tx-hash">${p.txHash ? escapeHtml(p.txHash.substring(0, 16)) + '...' : '-'}</td>
<td>${new Date(p.createdAt).toLocaleString()}</td>
<td>
${p.status === 'failed' || p.status === 'permanently_failed' ?
@@ -821,8 +821,8 @@
tbody.innerHTML = data.users.map(u => `
<tr>
<td>${u.id}</td>
<td title="${u.socketId}">${u.socketId ? u.socketId.substring(0, 15) + '...' : '-'}</td>
<td>${u.payoutAddress || '-'}</td>
<td title="${escapeHtml(u.socketId)}">${u.socketId ? escapeHtml(u.socketId.substring(0, 15)) + '...' : '-'}</td>
<td>${u.payoutAddress ? escapeHtml(u.payoutAddress) : '-'}</td>
<td>${u.credits || 0}</td>
<td>${u.gamesPlayed || 0}</td>
<td>${u.gamesWon || 0}</td>
@@ -879,9 +879,9 @@
<tr style="${rowStyle}">
<td>${m.id}</td>
<td>${formatTimeAgo(new Date(m.timestamp))}</td>
<td>${m.playerId || m.username || '-'}</td>
<td>${escapeHtml(m.playerId || m.username || '-')}</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(m.message)}">${escapeHtml(m.message)}</td>
<td>${m.userId || '-'}</td>
<td>${escapeHtml(m.userId || '-')}</td>
<td>
${isDeleted ? '<span class="status-badge status-failed">Deleted</span>' : ''}
${isBanned ? '<span class="status-badge status-failed">Banned</span>' : ''}
@@ -978,9 +978,15 @@
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
// Escape all five HTML-significant chars (including quotes) so the result is
// safe in both element text and quoted attribute values (e.g. title="...").
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatTimeAgo(date) {
+1
View File
@@ -58,6 +58,7 @@
<!-- Core libraries -->
<script src="js/lib/jquery-3.4.1.min.js"></script>
<script src="js/lib/rot.min.js"></script>
<script src="js/core/escapeHtml.js"></script>
<script src="js/core/options.js"></script>
<script src="js/ui/teletype.js"></script>
+18
View File
@@ -0,0 +1,18 @@
/**
* Shared HTML-escaping helper — the single source of truth for escaping untrusted
* (server- or user-supplied) text before it is inserted into innerHTML / jQuery .html().
* Escapes all five HTML-significant characters, including both quote types so the
* result is safe in both element text and quoted attribute values.
*/
(function (global) {
function escapeHtml(value) {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
global.escapeHtml = escapeHtml;
})(typeof window !== 'undefined' ? window : this);
+4 -2
View File
@@ -288,7 +288,9 @@ const SocketHandlers = {
onChatBroadcast: function(data) {
const msgElement = $('<li style="color: #aaa;">');
if (data.socketId) {
msgElement.html('<strong>' + data.socketId.substring(0, 6) + ':</strong> ' + data.message);
// Escape both fields — chat content is attacker-controlled. Defense in depth:
// the server also escapes, but the client must never trust that.
msgElement.html('<strong>' + escapeHtml(String(data.socketId).substring(0, 6)) + ':</strong> ' + escapeHtml(data.message));
} else {
msgElement.text(data.message);
}
@@ -320,7 +322,7 @@ const SocketHandlers = {
if (msg.timestamp) {
timeStr = '<span style="color:#555;font-size:10px;">[' + SocketHandlers._formatTimeAgo(new Date(msg.timestamp)) + ']</span> ';
}
msgElement.html(timeStr + '<strong>' + username + ':</strong> ' + msg.message);
msgElement.html(timeStr + '<strong>' + escapeHtml(username) + ':</strong> ' + escapeHtml(msg.message));
$('#messages').append(msgElement);
});
+7 -4
View File
@@ -127,12 +127,15 @@ const Leaderboard = {
},
_escapeHtml: function(str) {
// Delegate to the shared helper (single source of truth). Falls back to a local
// implementation if the helper somehow isn't loaded.
if (typeof window !== 'undefined' && typeof window.escapeHtml === 'function') {
return window.escapeHtml(str);
}
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
};