mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
4e4664b603
commit
6a0e5a28a9
|
|
@ -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'));
|
||||
|
|
|
|||
Loading…
Reference in a new issue