From 5f5ec88eb0436a8c35c199f59f26dde6f19b8bf0 Mon Sep 17 00:00:00 2001 From: screentinker Date: Thu, 16 Jul 2026 14:02:31 -0500 Subject: [PATCH] fix(widgets): put the CORP: cross-origin header on the route that actually serves content (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #195. The CORP fix there landed on routes/content.js `/:id/file`, but that handler is SHADOWED: server.js registers a public `app.get('/api/content/:id/file')` (and `/thumbnail`) BEFORE the auth-gated content router, and that public route (gated by playlist/widget reference) is what actually serves widget logo/background images. So the header never changed on the wire — origin still returned CORP: same-origin and the player's sandboxed (opaque-origin) widget iframe kept getting NS_ERROR_DOM_CORP_FAILED / 0 bytes. Set Access-Control-Allow-Origin: * + Cross-Origin-Resource-Policy: cross-origin on the real public routes in server.js: /file, /thumbnail (local), and the remote-thumbnail proxy. Revert the now-dead content.js edit so the fix lives only where the bytes are served. Co-authored-by: Claude Opus 4.8 --- server/routes/content.js | 9 --------- server/server.js | 12 ++++++++++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/server/routes/content.js b/server/routes/content.js index a9e7880..5676a41 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -347,12 +347,6 @@ router.get('/:id/file', (req, res) => { // Prevent path traversal const safePath = path.resolve(config.contentDir, path.basename(content.filepath)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); - // Widget boards (logo/background images) render inside the player's sandboxed - // (opaque-origin) widget iframe, so these image requests are cross-origin. Without - // CORP: cross-origin the helmet default (same-origin) blocks them (NS_ERROR_DOM_CORP_FAILED, - // 0 bytes). Matches the /uploads/content static route. Content already serves publicly. - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.sendFile(safePath); }); @@ -363,9 +357,6 @@ router.get('/:id/thumbnail', (req, res) => { if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' }); const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); - // See /:id/file — cross-origin so sandboxed widget iframes can load it. - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.sendFile(safePath); }); diff --git a/server/server.js b/server/server.js index 0bc21b3..c0156c8 100644 --- a/server/server.js +++ b/server/server.js @@ -469,6 +469,13 @@ app.get('/api/content/:id/file', (req, res) => { if (!inPlaylist && !inWidget && !requesterCanAccessContent(req, content)) return res.status(403).json({ error: 'Content not assigned to any playlist or widget' }); const safePath = path.resolve(config.contentDir, path.basename(content.filepath)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); + // Widget boards (logo / background images) render inside the player's sandboxed + // (opaque-origin) widget iframe, so these image loads are cross-origin. The helmet + // default CORP: same-origin blocks them (NS_ERROR_DOM_CORP_FAILED, 0 bytes). Allow + // cross-origin — matches the /uploads/content static route; this content is already + // served here without auth (playlist/widget-gated above). + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.sendFile(safePath); }); @@ -488,6 +495,8 @@ async function proxyRemoteThumbnail(url, res) { const buf = Buffer.from(await upstream.arrayBuffer()); res.set('Content-Type', ct); res.set('Cache-Control', 'public, max-age=86400'); + res.set('Access-Control-Allow-Origin', '*'); + res.set('Cross-Origin-Resource-Policy', 'cross-origin'); // load in sandboxed widget iframes return res.send(buf); } catch (e) { return res.status(502).json({ error: 'Thumbnail fetch failed' }); @@ -513,6 +522,9 @@ app.get('/api/content/:id/thumbnail', (req, res) => { if (/^https?:\/\//i.test(content.thumbnail_path)) return proxyRemoteThumbnail(content.thumbnail_path, res); const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); + // See /file — cross-origin so sandboxed widget iframes can load it. + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.sendFile(safePath); });