This commit is contained in:
Slop Master Flex 2026-07-25 00:26:25 +00:00
parent 0c30f7a252
commit b557cb678f
11 changed files with 58 additions and 41 deletions

View File

@ -77,6 +77,15 @@ The client uses `track.id` for:
- Fetching audio (`/api/tracks/:trackId`) - Fetching audio (`/api/tracks/:trackId`)
- Checking cache status (`M.cachedTracks.has(trackId)`) - Checking cache status (`M.cachedTracks.has(trackId)`)
## XSS Prevention
All server-controlled strings (channel names, usernames, track titles, playlist names, toast messages) must be escaped before reaching `innerHTML`:
- **`M.escapeHtml(str)`** (`public/utils.js`) — the single shared escaping helper. Do not define local copies.
- **`M.trackComponent.getTitle(track)`** (`public/trackComponent.js`) — the single source of truth for a track's display title. Use it instead of inline `track.title || track.filename` fallbacks.
A Content-Security-Policy header is set on all static responses in `routes/static.ts` (`script-src 'self'`, no inline scripts) as defense-in-depth.
## Client Caching System ## Client Caching System
### Segment-Based Buffering ### Segment-Based Buffering

View File

@ -159,7 +159,7 @@
counts[name] = (counts[name] || 0) + 1; counts[name] = (counts[name] || 0) + 1;
} }
const listenersHtml = Object.entries(counts).map(([name, count]) => const listenersHtml = Object.entries(counts).map(([name, count]) =>
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>` `<div class="listener">${M.escapeHtml(name)}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
).join(""); ).join("");
// Show delete button for non-default channels if user is admin or creator // Show delete button for non-default channels if user is admin or creator
@ -173,8 +173,8 @@
div.innerHTML = ` div.innerHTML = `
<div class="channel-header"> <div class="channel-header">
<span class="channel-name">${ch.name}</span> <span class="channel-name">${M.escapeHtml(ch.name)}</span>
<input class="channel-name-input" type="text" value="${ch.name.replace(/"/g, '&quot;')}" style="display:none;"> <input class="channel-name-input" type="text" value="${M.escapeHtml(ch.name)}" style="display:none;">
${renameBtn} ${renameBtn}
${deleteBtn} ${deleteBtn}
<span class="listener-count">${ch.listenerCount}</span> <span class="listener-count">${ch.listenerCount}</span>
@ -498,7 +498,7 @@
const isNewTrack = trackId !== M.currentTrackId; const isNewTrack = trackId !== M.currentTrackId;
if (isNewTrack) { if (isNewTrack) {
M.currentTrackId = trackId; M.currentTrackId = trackId;
M.setTrackTitle(data.track.title); M.setTrackTitle(M.trackComponent.getTitle(data.track));
M.applyReplayGain && M.applyReplayGain(data.track); M.applyReplayGain && M.applyReplayGain(data.track);
M.loadingSegments.clear(); M.loadingSegments.clear();

2
public/controls.js vendored
View File

@ -71,7 +71,7 @@
M.currentIndex = newIndex; M.currentIndex = newIndex;
M.currentTrackId = trackId; M.currentTrackId = trackId;
M.serverTrackDuration = track.duration; M.serverTrackDuration = track.duration;
M.setTrackTitle(track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown"); M.setTrackTitle(M.trackComponent.getTitle(track));
M.applyReplayGain && M.applyReplayGain(track); M.applyReplayGain && M.applyReplayGain(track);
M.loadingSegments.clear(); M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(trackId); const cachedUrl = await M.loadTrackBlob(trackId);

View File

@ -37,7 +37,7 @@
// Set up and play track // Set up and play track
M.currentTrackId = trackId; M.currentTrackId = trackId;
M.serverTrackDuration = track.duration; M.serverTrackDuration = track.duration;
M.setTrackTitle(track.title || track.filename); M.setTrackTitle(M.trackComponent.getTitle(track));
M.applyReplayGain && M.applyReplayGain(track); M.applyReplayGain && M.applyReplayGain(track);
M.loadingSegments.clear(); M.loadingSegments.clear();

View File

@ -41,7 +41,7 @@
} else { } else {
myContainer.innerHTML = myPlaylists.map(p => ` myContainer.innerHTML = myPlaylists.map(p => `
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}"> <div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
<span class="playlist-name">${escapeHtml(p.name)}</span> <span class="playlist-name">${M.escapeHtml(p.name)}</span>
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''} ${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
<span class="playlist-count">${p.trackIds.length}</span> <span class="playlist-count">${p.trackIds.length}</span>
</div> </div>
@ -54,8 +54,8 @@
} else { } else {
sharedContainer.innerHTML = sharedPlaylists.map(p => ` sharedContainer.innerHTML = sharedPlaylists.map(p => `
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}"> <div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
<span class="playlist-name">${escapeHtml(p.name)}</span> <span class="playlist-name">${M.escapeHtml(p.name)}</span>
<span class="playlist-owner">by ${escapeHtml(p.ownerName || 'Unknown')}</span> <span class="playlist-owner">by ${M.escapeHtml(p.ownerName || 'Unknown')}</span>
<span class="playlist-count">${p.trackIds.length}</span> <span class="playlist-count">${p.trackIds.length}</span>
</div> </div>
`).join(''); `).join('');
@ -478,13 +478,6 @@
})); }));
} }
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function initPlaylists() { function initPlaylists() {
// New playlist button // New playlist button
const btnNew = $('#btn-new-playlist'); const btnNew = $('#btn-new-playlist');

View File

@ -338,8 +338,8 @@
return; return;
} }
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); const title = M.trackComponent.getTitle(track);
bar.innerHTML = `<span class="label">Now playing:</span> ${title}`; bar.innerHTML = `<span class="label">Now playing:</span> ${M.escapeHtml(title)}`;
bar.title = title; bar.title = title;
bar.classList.remove("hidden"); bar.classList.remove("hidden");
}; };

View File

@ -4,6 +4,11 @@
(function() { (function() {
const M = window.MusicRoom; const M = window.MusicRoom;
// Single source of truth for a track's display title
function getTitle(track) {
return track?.title?.trim() || (track?.filename || track?.id || "Unknown").replace(/\.[^.]+$/, "");
}
/** /**
* Render a track row element (pure rendering, no handlers) * Render a track row element (pure rendering, no handlers)
* @param {Object} track - Track object with id, title, filename, duration * @param {Object} track - Track object with id, title, filename, duration
@ -45,7 +50,7 @@
div.dataset.view = view; div.dataset.view = view;
// Build title // Build title
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); const title = getTitle(track);
div.title = title; div.title = title;
// Build HTML // Build HTML
@ -56,7 +61,7 @@
${checkmark} ${checkmark}
<span class="cache-indicator"></span> <span class="cache-indicator"></span>
${trackNum} ${trackNum}
<span class="track-title">${escapeHtml(title)}</span> <span class="track-title">${M.escapeHtml(title)}</span>
<span class="track-actions"> <span class="track-actions">
<span class="duration">${M.fmt(track.duration)}</span> <span class="duration">${M.fmt(track.duration)}</span>
<button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button> <button type="button" class="track-menu-btn" title="Track actions" aria-label="Track actions"></button>
@ -70,17 +75,10 @@
return div; return div;
} }
// HTML escape helper
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
// Export // Export
M.trackComponent = { M.trackComponent = {
render, render,
escapeHtml getTitle,
escapeHtml: M.escapeHtml
}; };
})(); })();

View File

@ -597,7 +597,7 @@
async function playTrack(track, index) { async function playTrack(track, index) {
const trackId = track.id || track.filename; const trackId = track.id || track.filename;
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); const title = M.trackComponent.getTitle(track);
if (type === 'queue') { if (type === 'queue') {
// Jump to track in queue // Jump to track in queue
@ -633,7 +633,7 @@
async function previewTrack(track) { async function previewTrack(track) {
const trackId = track.id || track.filename; const trackId = track.id || track.filename;
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); const title = M.trackComponent.getTitle(track);
M.currentTrackId = trackId; M.currentTrackId = trackId;
M.serverTrackDuration = track.duration; M.serverTrackDuration = track.duration;
@ -658,7 +658,7 @@
function showContextMenu(e, track, index, canEditQueue) { function showContextMenu(e, track, index, canEditQueue) {
const trackId = track.id || track.filename; const trackId = track.id || track.filename;
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); const title = M.trackComponent.getTitle(track);
const sel = selection[type]; const sel = selection[type];
const hasSelection = sel.size > 0; const hasSelection = sel.size > 0;

View File

@ -304,14 +304,14 @@
for (const [playlistId, group] of byPlaylist) { for (const [playlistId, group] of byPlaylist) {
if (group.name) { if (group.name) {
html += `<div class="slow-queue-playlist-header">📁 ${group.name}</div>`; html += `<div class="slow-queue-playlist-header">📁 ${M.escapeHtml(group.name)}</div>`;
} }
html += group.items.map((item, i) => { html += group.items.map((item, i) => {
const isNext = queuedItems.indexOf(item) === 0; const isNext = queuedItems.indexOf(item) === 0;
return ` return `
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}"> <div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span> <span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
<span class="slow-queue-item-title">${item.title}</span> <span class="slow-queue-item-title">${M.escapeHtml(item.title)}</span>
<button class="slow-queue-cancel" title="Cancel"></button> <button class="slow-queue-cancel" title="Cancel"></button>
</div> </div>
`; `;

View File

@ -7,6 +7,18 @@
// DOM selector helper // DOM selector helper
M.$ = (s) => document.querySelector(s); M.$ = (s) => document.querySelector(s);
// Shared HTML escaping helper - use for any server-controlled string
// interpolated into innerHTML (text and attribute contexts)
M.escapeHtml = function(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
// Format seconds as m:ss // Format seconds as m:ss
M.fmt = function(sec) { M.fmt = function(sec) {
if (!sec || !isFinite(sec)) return "0:00"; if (!sec || !isFinite(sec)) return "0:00";
@ -110,7 +122,7 @@
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "history-item history-" + item.type; div.className = "history-item history-" + item.type;
const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
div.innerHTML = `<span class="history-time">${time}</span> ${item.message}`; div.innerHTML = `<span class="history-time">${time}</span> ${M.escapeHtml(item.message)}`;
list.appendChild(div); list.appendChild(div);
} }
}; };
@ -146,7 +158,7 @@
document.title = title ? `${title} - MusicRoom` : "MusicRoom"; document.title = title ? `${title} - MusicRoom` : "MusicRoom";
// First set simple content to measure // First set simple content to measure
marqueeEl.innerHTML = `<span id="track-title">${title}</span>`; marqueeEl.innerHTML = `<span id="track-title">${M.escapeHtml(title)}</span>`;
// Check if title overflows and needs scrolling // Check if title overflows and needs scrolling
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -156,7 +168,7 @@
// Duplicate text for seamless wrap-around scrolling // Duplicate text for seamless wrap-around scrolling
if (needsScroll) { if (needsScroll) {
marqueeEl.innerHTML = `<span id="track-title">${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span><span>${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span>`; marqueeEl.innerHTML = `<span id="track-title">${M.escapeHtml(title)}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span><span>${M.escapeHtml(title)}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span>`;
} }
}); });
}; };

View File

@ -3,22 +3,27 @@ import { join } from "path";
import { PUBLIC_DIR } from "../config"; import { PUBLIC_DIR } from "../config";
// Serve static files // Serve static files
// Defense-in-depth CSP: no inline scripts, same-origin + ws(s) connections,
// blob: media for cached audio, data:/blob: images, inline styles allowed
// (style attributes are used throughout the client).
const CSP = "default-src 'self'; script-src 'self'; connect-src 'self' ws: wss:; media-src 'self' blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'";
export async function handleStatic(path: string): Promise<Response | null> { export async function handleStatic(path: string): Promise<Response | null> {
if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) { if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) {
return new Response(file(join(PUBLIC_DIR, "index.html")), { return new Response(file(join(PUBLIC_DIR, "index.html")), {
headers: { "Content-Type": "text/html" }, headers: { "Content-Type": "text/html", "Content-Security-Policy": CSP },
}); });
} }
if (path === "/styles.css") { if (path === "/styles.css") {
return new Response(file(join(PUBLIC_DIR, "styles.css")), { return new Response(file(join(PUBLIC_DIR, "styles.css")), {
headers: { "Content-Type": "text/css" }, headers: { "Content-Type": "text/css", "Content-Security-Policy": CSP },
}); });
} }
if (path === "/favicon.ico") { if (path === "/favicon.ico") {
return new Response(file(join(PUBLIC_DIR, "favicon.ico")), { return new Response(file(join(PUBLIC_DIR, "favicon.ico")), {
headers: { "Content-Type": "image/x-icon" }, headers: { "Content-Type": "image/x-icon", "Content-Security-Policy": CSP },
}); });
} }
@ -26,7 +31,7 @@ export async function handleStatic(path: string): Promise<Response | null> {
const jsFile = file(join(PUBLIC_DIR, path.slice(1))); const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
if (await jsFile.exists()) { if (await jsFile.exists()) {
return new Response(jsFile, { return new Response(jsFile, {
headers: { "Content-Type": "application/javascript" }, headers: { "Content-Type": "application/javascript", "Content-Security-Policy": CSP },
}); });
} }
} }