diff --git a/src/components/YouTubeLinkCard.jsx b/src/components/YouTubeLinkCard.jsx
index 2ab8748..023e4dc 100644
--- a/src/components/YouTubeLinkCard.jsx
+++ b/src/components/YouTubeLinkCard.jsx
@@ -1,6 +1,23 @@
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)
+ // 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 {
+ /* 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 +49,7 @@ export default function YouTubeLinkCard({ yt, copied }) {
{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..0006b4b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,38 @@ 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', + // 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 `), + }, +}) + // 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 +47,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