mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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>
125 lines
5.1 KiB
JavaScript
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;
|