fix(widgets): put the CORP: cross-origin header on the route that actually serves content (#196)

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 <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-16 14:02:31 -05:00 committed by GitHub
parent 178af029a4
commit 5f5ec88eb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 12 additions and 9 deletions

View file

@ -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);
});

View file

@ -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);
});