From b78ae52fc0136c54b80ca272931a282e0e1ab0e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 01:08:09 +0000 Subject: [PATCH 1/2] feat(security): add CSP + validate third-party thumbnail + encode id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth hardening (no active vuln — the app is keyless and renders all external strings as escaped React text): - Content-Security-Policy: injected into index.html for PRODUCTION BUILDS ONLY (dev/HMR needs inline+eval) via a small Vite plugin. Locks default-src to 'self', restricts img to YouTube's CDNs, connect to noembed+lrclib, media to self+blob, and forbids object/base-uri. style-src keeps 'unsafe-inline' for React's inline style attributes (never for script). - YouTubeLinkCard: the thumbnail URL comes from noembed's response, so validate it (https + a ytimg/ggpht host) before using it as an ; drop it otherwise (the card still shows title/author). - youtube.js: encodeURIComponent the id in the noembed request URL — a no-op for a valid 11-char id, but prevents query-param injection if an unvalidated id ever reaches it. Verified against the PRODUCTION build served under /melody/: app renders, styles/fonts apply, media blob: URLs play, and there are zero CSP violations or console errors. Fixes #18. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FkDKh1Uo1a4D7n5wCKdcKF --- src/components/YouTubeLinkCard.jsx | 17 +++++++++++++++- src/lib/youtube.js | 4 +++- vite.config.js | 31 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/components/YouTubeLinkCard.jsx b/src/components/YouTubeLinkCard.jsx index 2ab8748..a8bd3ea 100644 --- a/src/components/YouTubeLinkCard.jsx +++ b/src/components/YouTubeLinkCard.jsx @@ -1,6 +1,21 @@ import { useState, useEffect } from 'react' import { buildYtDlpCommand, fetchYouTubePreview } from '../lib/youtube' +// The thumbnail URL comes from a third-party (noembed) response, so validate it +// before using it as an : require https and a YouTube-owned host, +// otherwise drop it (the card still shows the title/author). +function safeThumb(url) { + try { + const u = new URL(url) + if (u.protocol === 'https:' && /(^|\.)(ytimg\.com|ggpht\.com)$/i.test(u.hostname)) { + return url + } + } catch { + /* not a valid URL */ + } + return null +} + // Shown in Search when a YouTube link is present. Previews the video and offers // the a-Shell command. The command box itself is the copy control (tap to copy) // — and pasting via the search bar pre-copies it, so usually it's already done. @@ -32,7 +47,7 @@ export default function YouTubeLinkCard({ yt, copied }) {
{preview ? (
- + {safeThumb(preview.thumbnail) && }

{preview.title}

{preview.author}

diff --git a/src/lib/youtube.js b/src/lib/youtube.js index b468ad5..8338105 100644 --- a/src/lib/youtube.js +++ b/src/lib/youtube.js @@ -36,8 +36,10 @@ export function buildYtDlpCommand(url) { // unlike YouTube's own oEmbed — sends CORS headers so the browser can read it. export async function fetchYouTubePreview(id) { try { + // encodeURIComponent(id) is a no-op for a valid 11-char id, but hardens the + // request against query-param injection if an unvalidated id ever reaches here. const res = await fetch( - `https://noembed.com/embed?url=https://www.youtube.com/watch?v=${id}`, + `https://noembed.com/embed?url=https://www.youtube.com/watch?v=${encodeURIComponent(id)}`, ) if (!res.ok) return null const d = await res.json() diff --git a/vite.config.js b/vite.config.js index c5b8349..10b2cf9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,35 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' +// Content-Security-Policy (defense-in-depth). Injected into index.html for +// PRODUCTION BUILDS ONLY — dev/HMR needs inline scripts + eval, which this would +// block. Sources: app assets are same-origin ('self'); thumbnails come from +// YouTube's image CDNs; JSON is fetched from noembed + lrclib; imported audio +// and artwork play from blob: URLs; React sets inline style attributes +// ('unsafe-inline' for style only, never script). +const CSP = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https://i.ytimg.com https://*.ytimg.com https://*.ggpht.com", + "media-src 'self' blob:", + "connect-src 'self' https://noembed.com https://lrclib.net", + "font-src 'self'", + "manifest-src 'self'", + "worker-src 'self'", + "object-src 'none'", + "base-uri 'none'", +].join('; ') + +const cspPlugin = () => ({ + name: 'melody-csp', + transformIndexHtml: { + order: 'post', + handler: (html) => + html.replace('', ` \n `), + }, +}) + // NOTE on `base`: production is served from a SUBPATH, so the built asset URLs // must be prefixed with it or the deployed app loads a blank white screen (the // JS/CSS 404). The live deploy is Firebase via `npm run deploy:sparky`, hosted @@ -15,6 +44,8 @@ import { VitePWA } from 'vite-plugin-pwa' export default defineConfig(({ command }) => ({ base: command === 'build' ? '/melody/' : '/', plugins: [ + // CSP only in the built HTML — injecting it in dev would break Vite HMR. + command === 'build' && cspPlugin(), react(), VitePWA({ // 'autoUpdate' = the new service worker activates and reloads the page as From a9edc5e9cda4d14f8e41e5848d1f98ed1dc110b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 01:13:50 +0000 Subject: [PATCH 2/2] security: place CSP first in head + align safeThumb to CSP hosts Review follow-ups: inject the CSP meta as the first head child so it governs the app bundle/CSS/font fetches (a meta CSP doesn't apply to subresources requested before it's parsed); and require a *.ytimg.com / *.ggpht.com subdomain in safeThumb to match the img-src allowlist (the CSP doesn't permit the bare apex). Re-verified against the production build: renders, styles/fonts apply, zero CSP violations. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FkDKh1Uo1a4D7n5wCKdcKF --- src/components/YouTubeLinkCard.jsx | 4 +++- vite.config.js | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/YouTubeLinkCard.jsx b/src/components/YouTubeLinkCard.jsx index a8bd3ea..023e4dc 100644 --- a/src/components/YouTubeLinkCard.jsx +++ b/src/components/YouTubeLinkCard.jsx @@ -7,7 +7,9 @@ import { buildYtDlpCommand, fetchYouTubePreview } from '../lib/youtube' function safeThumb(url) { try { const u = new URL(url) - if (u.protocol === 'https:' && /(^|\.)(ytimg\.com|ggpht\.com)$/i.test(u.hostname)) { + // Require a subdomain of ytimg.com / ggpht.com (matches the img-src CSP, + // which allows *.ytimg.com / *.ggpht.com, not the bare apex). + if (u.protocol === 'https:' && /\.(ytimg|ggpht)\.com$/i.test(u.hostname)) { return url } } catch { diff --git a/vite.config.js b/vite.config.js index 10b2cf9..0006b4b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -26,8 +26,11 @@ const cspPlugin = () => ({ name: 'melody-csp', transformIndexHtml: { order: 'post', + // Inject as the FIRST head child so the policy governs every subresource + // (the app bundle, CSS, fonts) — a meta CSP doesn't apply to requests made + // before it's parsed. handler: (html) => - html.replace('', ` \n `), + html.replace('', `\n `), }, })