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`)
- 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
### Segment-Based Buffering

View File

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

2
public/controls.js vendored
View File

@ -71,7 +71,7 @@
M.currentIndex = newIndex;
M.currentTrackId = trackId;
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.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(trackId);

View File

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

View File

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

View File

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

View File

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

View File

@ -597,7 +597,7 @@
async function playTrack(track, index) {
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') {
// Jump to track in queue
@ -633,7 +633,7 @@
async function previewTrack(track) {
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.serverTrackDuration = track.duration;
@ -658,7 +658,7 @@
function showContextMenu(e, track, index, canEditQueue) {
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 hasSelection = sel.size > 0;

View File

@ -304,14 +304,14 @@
for (const [playlistId, group] of byPlaylist) {
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) => {
const isNext = queuedItems.indexOf(item) === 0;
return `
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
<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>
</div>
`;

View File

@ -6,6 +6,18 @@
// DOM selector helper
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
M.fmt = function(sec) {
@ -110,7 +122,7 @@
const div = document.createElement("div");
div.className = "history-item history-" + item.type;
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);
}
};
@ -146,7 +158,7 @@
document.title = title ? `${title} - MusicRoom` : "MusicRoom";
// 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
requestAnimationFrame(() => {
@ -156,7 +168,7 @@
// Duplicate text for seamless wrap-around scrolling
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";
// 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> {
if (path === "/" || path === "/index.html" || path.startsWith("/listen/")) {
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") {
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") {
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)));
if (await jsFile.exists()) {
return new Response(jsFile, {
headers: { "Content-Type": "application/javascript" },
headers: { "Content-Type": "application/javascript", "Content-Security-Policy": CSP },
});
}
}