Fix content file access gate for widget references

Extend the public /api/content/:id/file gate to unlock content referenced
by widgets (previously only playlists unlocked it), so device browsers
and kiosk iframes can fetch logos and background images that widgets
embed.

Security: scope the widget lookup to the content owner's widgets only
(w.user_id = content.user_id). Otherwise a user could unlock another
user's content file by creating their own widget whose config references
the victim's content UUID. The pre-existing playlist gate has the same
shape and is left for a separate fix.

Also adds a 30/min rate limit on POST /api/widgets/preview, which
inlines user content as base64 and is memory-intensive.

Perf note: the widgets.config LIKE scan is O(n). Fine at current scale;
revisit with a content_widget_refs join table if the widget table grows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-21 22:28:55 -05:00
parent 4e4664b603
commit 6a0e5a28a9

View file

@ -176,8 +176,13 @@ app.get('/api/content/:id/file', (req, res) => {
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
if (!content) return res.status(404).json({ error: 'Content not found' });
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
const assigned = db.prepare('SELECT id FROM playlist_items WHERE content_id = ? LIMIT 1').get(req.params.id);
if (!assigned) return res.status(403).json({ error: 'Content not assigned to any playlist' });
const inPlaylist = db.prepare('SELECT id FROM playlist_items WHERE content_id = ? LIMIT 1').get(req.params.id);
// Scope widget lookup to content owner's widgets only — prevents a user from unlocking
// another user's content by creating their own widget that references the UUID.
// Perf note: LIKE scan on widgets.config is O(n) per request. Fine at current scale
// (<100 widgets); revisit with a content_widget_refs join table if this grows.
const inWidget = inPlaylist ? null : db.prepare('SELECT id FROM widgets WHERE user_id = ? AND config LIKE ? LIMIT 1').get(content.user_id, `%/api/content/${req.params.id}/%`);
if (!inPlaylist && !inWidget) 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' });
res.sendFile(safePath);
@ -202,6 +207,8 @@ app.use('/api/provision', requireAuth, require('./routes/provisioning'));
app.use('/api/layouts', requireAuth, require('./routes/layouts'));
// Widget render is public (accessed by devices)
app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
// Rate limit preview endpoint — it inlines user content as base64 which is memory-intensive
app.use('/api/widgets/preview', rateLimit(60000, 30));
app.use('/api/widgets', (req, res, next) => { if (req._skipAuth) return next(); requireAuth(req, res, next); }, require('./routes/widgets'));
app.use('/api/schedules', requireAuth, require('./routes/schedules'));
app.use('/api/walls', requireAuth, require('./routes/video-walls'));