From b557cb678f3e89b7758bf9ad1438afc157ba5b4b Mon Sep 17 00:00:00 2001 From: Slop Master Flex Date: Sat, 25 Jul 2026 00:26:25 +0000 Subject: [PATCH] saving --- AGENTS.md | 9 +++++++++ public/channelSync.js | 8 ++++---- public/controls.js | 2 +- public/init.js | 2 +- public/playlists.js | 13 +++---------- public/queue.js | 4 ++-- public/trackComponent.js | 20 +++++++++----------- public/trackContainer.js | 6 +++--- public/upload.js | 4 ++-- public/utils.js | 18 +++++++++++++++--- routes/static.ts | 13 +++++++++---- 11 files changed, 58 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a5002b..ac0daa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/public/channelSync.js b/public/channelSync.js index 340eff5..18eb071 100644 --- a/public/channelSync.js +++ b/public/channelSync.js @@ -159,7 +159,7 @@ counts[name] = (counts[name] || 0) + 1; } const listenersHtml = Object.entries(counts).map(([name, count]) => - `
${name}${count > 1 ? ` x${count}` : ""}
` + `
${M.escapeHtml(name)}${count > 1 ? ` x${count}` : ""}
` ).join(""); // Show delete button for non-default channels if user is admin or creator @@ -173,8 +173,8 @@ div.innerHTML = `
- ${ch.name} - + ${M.escapeHtml(ch.name)} + ${renameBtn} ${deleteBtn} ${ch.listenerCount} @@ -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(); diff --git a/public/controls.js b/public/controls.js index 3ccf140..ae71300 100644 --- a/public/controls.js +++ b/public/controls.js @@ -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); diff --git a/public/init.js b/public/init.js index bc62084..6262c8f 100644 --- a/public/init.js +++ b/public/init.js @@ -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(); diff --git a/public/playlists.js b/public/playlists.js index 6071760..9b485f9 100644 --- a/public/playlists.js +++ b/public/playlists.js @@ -41,7 +41,7 @@ } else { myContainer.innerHTML = myPlaylists.map(p => `
- ${escapeHtml(p.name)} + ${M.escapeHtml(p.name)} ${p.isPublic ? '๐ŸŒ' : ''} ${p.trackIds.length}
@@ -54,8 +54,8 @@ } else { sharedContainer.innerHTML = sharedPlaylists.map(p => `
- ${escapeHtml(p.name)} - by ${escapeHtml(p.ownerName || 'Unknown')} + ${M.escapeHtml(p.name)} + by ${M.escapeHtml(p.ownerName || 'Unknown')} ${p.trackIds.length}
`).join(''); @@ -478,13 +478,6 @@ })); } - function escapeHtml(str) { - if (!str) return ''; - return str.replace(/[&<>"']/g, c => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - }[c])); - } - function initPlaylists() { // New playlist button const btnNew = $('#btn-new-playlist'); diff --git a/public/queue.js b/public/queue.js index c71ff6f..5b95729 100644 --- a/public/queue.js +++ b/public/queue.js @@ -338,8 +338,8 @@ return; } - const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); - bar.innerHTML = `Now playing: ${title}`; + const title = M.trackComponent.getTitle(track); + bar.innerHTML = `Now playing: ${M.escapeHtml(title)}`; bar.title = title; bar.classList.remove("hidden"); }; diff --git a/public/trackComponent.js b/public/trackComponent.js index 7df668a..3064581 100644 --- a/public/trackComponent.js +++ b/public/trackComponent.js @@ -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} ${trackNum} - ${escapeHtml(title)} + ${M.escapeHtml(title)} ${M.fmt(track.duration)} @@ -70,17 +75,10 @@ return div; } - // HTML escape helper - function escapeHtml(str) { - if (!str) return ''; - return str.replace(/[&<>"']/g, c => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - })[c]); - } - // Export M.trackComponent = { render, - escapeHtml + getTitle, + escapeHtml: M.escapeHtml }; })(); diff --git a/public/trackContainer.js b/public/trackContainer.js index 691cce2..92dec65 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -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; diff --git a/public/upload.js b/public/upload.js index d8964fe..67a3bba 100644 --- a/public/upload.js +++ b/public/upload.js @@ -304,14 +304,14 @@ for (const [playlistId, group] of byPlaylist) { if (group.name) { - html += `
๐Ÿ“ ${group.name}
`; + html += `
๐Ÿ“ ${M.escapeHtml(group.name)}
`; } html += group.items.map((item, i) => { const isNext = queuedItems.indexOf(item) === 0; return `
${isNext ? 'โณ' : 'ยท'} - ${item.title} + ${M.escapeHtml(item.title)}
`; diff --git a/public/utils.js b/public/utils.js index 21aea24..3a5ef27 100644 --- a/public/utils.js +++ b/public/utils.js @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; // 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 = `${time} ${item.message}`; + div.innerHTML = `${time} ${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 = `${title}`; + marqueeEl.innerHTML = `${M.escapeHtml(title)}`; // Check if title overflows and needs scrolling requestAnimationFrame(() => { @@ -156,7 +168,7 @@ // Duplicate text for seamless wrap-around scrolling if (needsScroll) { - marqueeEl.innerHTML = `${title}   โ€ข   ${title}   โ€ข   `; + marqueeEl.innerHTML = `${M.escapeHtml(title)}   โ€ข   ${M.escapeHtml(title)}   โ€ข   `; } }); }; diff --git a/routes/static.ts b/routes/static.ts index 284dab6..ebf5619 100644 --- a/routes/static.ts +++ b/routes/static.ts @@ -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 { 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 { 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 }, }); } }