screentinker/server/routes/folders.js
ScreenTinker c105a5941e Security: fix IDORs, XSS, rate limits, SSRF validation
HIGH 1 (teams IDOR): POST/DELETE /api/teams/:id/devices now require the
caller to own the device before assigning or detaching it. Without this
check, any team member could pull any device into their team via UUID
guess and gain remote-control access.

HIGH 2 (schedules IDOR): PUT /api/schedules/:id now re-verifies
ownership of every changed target field — device_id, group_id,
content_id, widget_id, layout_id, playlist_id. Previously only the
schedule owner was checked, letting users fire arbitrary content on
victim devices via update.

HIGH 3 (filename XSS): file.originalname captured by multer bypassed
sanitizeBody. New safeFilename() wraps every INSERT path (multipart
upload, remote URL, YouTube). Frontend sinks now go through esc() in
content-library.js, device-detail.js, video-wall.js. Web player gets
an inline escHtml helper for its info overlay where filenames, device
name, and serverUrl land in innerHTML.

HIGH 4 (kiosk public XSS): config.idleTimeout is now coerced via the
existing safeNumber() helper at both interpolation sites. A crafted
value with a newline can no longer escape the JS line comment to
inject arbitrary code into the public render endpoint.

HIGH 5 (folder DoS): POST /api/folders enforces a per-user cap of 100
folders (429 on overflow). Superadmin exempt.

MED 1 (SSRF): ImageLoader.decodeUrl rejects any URL scheme other than
http(s) so a malicious remote_url can't read local files via file://.
On the server, validateRemoteUrl() is extracted and now also runs on
PUT /api/content/:id remote_url updates — previously the SSRF check
only fired on POST.

MED 2 (fingerprint takeover): the WS device:register fingerprint
reclaim path now rejects takeover while the target device is online or
within 24h of its last heartbeat. A leaked fingerprint can no longer
hijack an active display.

MED 3 (npm audit): bumped uuid 9.x -> 14.0.0 (v3/v5/v6 buffer bounds
CVE; we only use v4 so not exploitable, but clears the audit). path-
to-regexp resolved to 0.1.13 via npm audit fix. 0 vulns remaining.

MED 4 (folder admin consistency): ownedFolder() and the content.js
folder_id move check now both treat only superadmin as privileged,
matching GET /api/folders. Previously a plain "admin" could rename
or delete folders they couldn't see, and could move content into
folders they couldn't list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:18 -05:00

125 lines
5.1 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Per-user folder cap. The route has no rate limit (multer doesn't go through the
// global API limiter chain), so without a count cap a single account could insert
// millions of rows. 100 is a generous ceiling for a real organisational hierarchy
// — admins/superadmins are exempt because they may manage cross-user data.
const MAX_FOLDERS_PER_USER = 100;
// Verify a folder belongs to the current user (or null = root, also allowed).
// Returns the row, or null if it exists but isn't owned by the user.
//
// Only superadmin gets cross-user access — matching the GET /api/folders listing
// (which has always been superadmin-only). The previous mismatch let a regular
// "admin" mutate folders they couldn't see, so the inconsistency was exploitable.
function ownedFolder(req, folderId) {
if (!folderId) return { id: null };
if (!UUID_RE.test(folderId)) return null;
const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId);
if (!row) return null;
const isSuperadmin = req.user.role === 'superadmin';
if (!isSuperadmin && row.user_id !== req.user.id) return null;
return row;
}
// List folders for the current user. Returns the full tree as a flat array;
// the client builds the hierarchy from parent_id.
router.get('/', (req, res) => {
const isAdmin = req.user.role === 'superadmin';
const rows = isAdmin
? db.prepare('SELECT * FROM content_folders ORDER BY name COLLATE NOCASE').all()
: db.prepare('SELECT * FROM content_folders WHERE user_id = ? ORDER BY name COLLATE NOCASE').all(req.user.id);
res.json(rows);
});
// Create a folder.
router.post('/', (req, res) => {
const name = (req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name is required' });
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
const isSuperadmin = req.user.role === 'superadmin';
if (!isSuperadmin) {
const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE user_id = ?').get(req.user.id);
if (count >= MAX_FOLDERS_PER_USER) {
return res.status(429).json({
error: `Folder limit reached (${MAX_FOLDERS_PER_USER}). Delete unused folders before creating more.`
});
}
}
const parentId = req.body.parent_id || null;
if (parentId) {
const parent = ownedFolder(req, parentId);
if (!parent || parent.id === null) return res.status(400).json({ error: 'Invalid parent_id' });
}
const id = uuidv4();
db.prepare(
'INSERT INTO content_folders (id, user_id, parent_id, name) VALUES (?, ?, ?, ?)'
).run(id, req.user.id, parentId, name);
res.status(201).json(db.prepare('SELECT * FROM content_folders WHERE id = ?').get(id));
});
// Rename / move a folder.
router.put('/:id', (req, res) => {
const folder = ownedFolder(req, req.params.id);
if (!folder || folder.id === null) return res.status(404).json({ error: 'Folder not found' });
const updates = [];
const values = [];
if (req.body.name !== undefined) {
const name = String(req.body.name).trim();
if (!name) return res.status(400).json({ error: 'name cannot be empty' });
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
updates.push('name = ?');
values.push(name);
}
if (req.body.parent_id !== undefined) {
const newParent = req.body.parent_id || null;
if (newParent === folder.id) return res.status(400).json({ error: 'Folder cannot be its own parent' });
if (newParent) {
const parent = ownedFolder(req, newParent);
if (!parent || parent.id === null) return res.status(400).json({ error: 'Invalid parent_id' });
// Reject cycles: walk up from the new parent and ensure we never hit this folder.
let cursor = parent;
const seen = new Set([folder.id]);
while (cursor && cursor.parent_id) {
if (seen.has(cursor.parent_id)) {
return res.status(400).json({ error: 'Move would create a cycle' });
}
seen.add(cursor.parent_id);
cursor = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(cursor.parent_id);
}
}
updates.push('parent_id = ?');
values.push(newParent);
}
if (updates.length === 0) return res.json(folder);
values.push(folder.id);
db.prepare(`UPDATE content_folders SET ${updates.join(', ')} WHERE id = ?`).run(...values);
res.json(db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folder.id));
});
// Delete a folder. Content inside it falls back to root via ON DELETE SET NULL.
// Subfolders cascade-delete; if the user wants to keep them they should move them first.
router.delete('/:id', (req, res) => {
const folder = ownedFolder(req, req.params.id);
if (!folder || folder.id === null) return res.status(404).json({ error: 'Folder not found' });
db.prepare('DELETE FROM content_folders WHERE id = ?').run(folder.id);
res.json({ success: true });
});
module.exports = router;