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>
400 lines
16 KiB
JavaScript
400 lines
16 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
const upload = require('../middleware/upload');
|
|
const config = require('../config');
|
|
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
|
|
const { sanitizeString } = require('../middleware/sanitize');
|
|
|
|
// Multer captures file.originalname directly from the multipart filename header,
|
|
// bypassing sanitizeBody. Apply the same HTML-escape here so a filename like
|
|
// `"><img src=x onerror=alert(1)>.jpg` is stored as `"><img...` and
|
|
// renders as text in every UI sink. Umlauts, spaces, dots, and other unicode are
|
|
// preserved — sanitizeString only touches `& < > " '`.
|
|
function safeFilename(name) {
|
|
return sanitizeString(name || '');
|
|
}
|
|
|
|
// SSRF gate for remote_url. Returns null if valid, else { status, error }.
|
|
// Used by both POST /remote and PUT /:id so a user can't bypass the check by
|
|
// uploading a benign URL and then PUT-updating it to file:///etc/passwd.
|
|
function validateRemoteUrl(url) {
|
|
let parsed;
|
|
try { parsed = new URL(url); }
|
|
catch { return { status: 400, error: 'Invalid URL format' }; }
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
return { status: 400, error: 'URL must use http or https' };
|
|
}
|
|
const hostname = parsed.hostname.toLowerCase();
|
|
const isPrivate = hostname === 'localhost' || hostname === '0.0.0.0' ||
|
|
hostname.startsWith('127.') || hostname.startsWith('10.') ||
|
|
hostname.startsWith('192.168.') || hostname.startsWith('169.254.') ||
|
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
|
hostname.startsWith('fc') || hostname.startsWith('fd') || hostname === '::1' ||
|
|
hostname.endsWith('.local') || hostname.endsWith('.internal');
|
|
if (isPrivate) return { status: 400, error: 'Internal URLs are not allowed' };
|
|
return null;
|
|
}
|
|
|
|
// List content for current user (admins see all).
|
|
// folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder.
|
|
router.get('/', (req, res) => {
|
|
const isAdmin = req.user.role === 'superadmin';
|
|
const folder = req.query.folder;
|
|
const folderId = req.query.folder_id;
|
|
let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`;
|
|
const params = isAdmin ? [] : [req.user.id];
|
|
if (folder) { sql += ' AND folder = ?'; params.push(folder); }
|
|
if (folderId !== undefined) {
|
|
if (folderId === 'root' || folderId === '') {
|
|
sql += ' AND folder_id IS NULL';
|
|
} else {
|
|
sql += ' AND folder_id = ?';
|
|
params.push(folderId);
|
|
}
|
|
}
|
|
sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?';
|
|
params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0);
|
|
const content = db.prepare(sql).all(...params);
|
|
res.json(content);
|
|
});
|
|
|
|
// Get folders list
|
|
router.get('/folders', (req, res) => {
|
|
const isAdmin = req.user.role === 'superadmin';
|
|
const folders = db.prepare(
|
|
`SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL ${isAdmin ? '' : 'AND (user_id = ? OR user_id IS NULL)'} GROUP BY folder ORDER BY folder`
|
|
).all(...(isAdmin ? [] : [req.user.id]));
|
|
res.json(folders);
|
|
});
|
|
|
|
// Upload content
|
|
router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
|
|
try {
|
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
|
|
|
const id = uuidv4();
|
|
const filepath = req.file.filename;
|
|
let width = null, height = null, durationSec = null, thumbnailPath = null;
|
|
|
|
// Try to generate thumbnail, get dimensions, and detect duration
|
|
try {
|
|
if (req.file.mimetype.startsWith('image/')) {
|
|
const sharp = require('sharp');
|
|
const metadata = await sharp(req.file.path).metadata();
|
|
width = metadata.width;
|
|
height = metadata.height;
|
|
|
|
// Generate thumbnail
|
|
thumbnailPath = `thumb_${filepath}`;
|
|
await sharp(req.file.path)
|
|
.resize(config.thumbnailWidth)
|
|
.jpeg({ quality: 70 })
|
|
.toFile(path.join(config.contentDir, thumbnailPath));
|
|
} else if (req.file.mimetype.startsWith('video/')) {
|
|
// Extract video duration and dimensions with ffprobe
|
|
try {
|
|
const { execFileSync } = require('child_process');
|
|
// Use execFileSync (not execSync) to prevent shell injection - args are NOT passed through shell
|
|
const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', req.file.path],
|
|
{ timeout: 15000 }
|
|
).toString();
|
|
const info = JSON.parse(probe);
|
|
if (info.format?.duration) durationSec = parseFloat(info.format.duration);
|
|
const videoStream = info.streams?.find(s => s.codec_type === 'video');
|
|
if (videoStream) {
|
|
width = videoStream.width;
|
|
height = videoStream.height;
|
|
}
|
|
// Generate video thumbnail at 2 second mark
|
|
thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`;
|
|
try {
|
|
execFileSync('ffmpeg', ['-y', '-i', req.file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)],
|
|
{ timeout: 15000 }
|
|
);
|
|
} catch { thumbnailPath = null; }
|
|
} catch (e) {
|
|
console.warn('ffprobe failed:', e.message);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Thumbnail/metadata generation failed:', e.message);
|
|
}
|
|
|
|
db.prepare(`
|
|
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(id, req.user.id, safeFilename(req.file.originalname), filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height);
|
|
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
|
res.status(201).json(content);
|
|
} catch (err) {
|
|
console.error('Upload error:', err);
|
|
res.status(500).json({ error: 'Upload failed' });
|
|
}
|
|
});
|
|
|
|
// Add remote URL content
|
|
router.post('/remote', checkRemoteUrl, (req, res) => {
|
|
try {
|
|
const { url, name, mime_type } = req.body;
|
|
if (!url) return res.status(400).json({ error: 'url is required' });
|
|
const urlErr = validateRemoteUrl(url);
|
|
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
|
|
|
|
const id = uuidv4();
|
|
const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content';
|
|
const mimeType = mime_type || (url.match(/\.(mp4|webm|mkv|avi|mov)/i) ? 'video/mp4' : 'image/jpeg');
|
|
|
|
db.prepare(`
|
|
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url)
|
|
VALUES (?, ?, ?, '', ?, 0, ?)
|
|
`).run(id, req.user.id, safeFilename(filename), mimeType, url);
|
|
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
|
res.status(201).json(content);
|
|
} catch (err) {
|
|
console.error('Remote URL add error:', err);
|
|
res.status(500).json({ error: 'Failed to add remote URL' });
|
|
}
|
|
});
|
|
|
|
// Add YouTube content (available to all plans - no storage used)
|
|
router.post('/youtube', async (req, res) => {
|
|
try {
|
|
const { url, name } = req.body;
|
|
if (!url) return res.status(400).json({ error: 'url is required' });
|
|
|
|
// Extract YouTube video ID from various URL formats
|
|
const videoId = extractYoutubeId(url);
|
|
if (!videoId) return res.status(400).json({ error: 'Invalid YouTube URL' });
|
|
|
|
// Fetch video title from YouTube oEmbed if no name provided
|
|
let filename = name;
|
|
if (!filename) {
|
|
try {
|
|
const oembedRes = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
|
|
if (oembedRes.ok) {
|
|
const oembed = await oembedRes.json();
|
|
filename = oembed.title;
|
|
}
|
|
} catch {}
|
|
}
|
|
if (!filename) filename = `YouTube: ${videoId}`;
|
|
|
|
const id = uuidv4();
|
|
const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=${videoId}&enablejsapi=1`;
|
|
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
|
|
|
db.prepare(`
|
|
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path)
|
|
VALUES (?, ?, ?, '', 'video/youtube', 0, ?, ?)
|
|
`).run(id, req.user.id, safeFilename(filename), embedUrl, thumbnailUrl);
|
|
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
|
res.status(201).json(content);
|
|
} catch (err) {
|
|
console.error('YouTube add error:', err);
|
|
res.status(500).json({ error: 'Failed to add YouTube video' });
|
|
}
|
|
});
|
|
|
|
function extractYoutubeId(url) {
|
|
const patterns = [
|
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
|
/^([a-zA-Z0-9_-]{11})$/ // bare video ID
|
|
];
|
|
for (const p of patterns) {
|
|
const m = url.match(p);
|
|
if (m) return m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Helper: check content ownership
|
|
function checkContentAccess(req, res) {
|
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
|
|
if (!content) { res.status(404).json({ error: 'Content not found' }); return null; }
|
|
if (!['admin','superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) {
|
|
res.status(403).json({ error: 'Access denied' }); return null;
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// Get content metadata
|
|
router.get('/:id', (req, res) => {
|
|
const content = checkContentAccess(req, res);
|
|
if (!content) return;
|
|
res.json(content);
|
|
});
|
|
|
|
// Update content metadata
|
|
router.put('/:id', (req, res) => {
|
|
const content = checkContentAccess(req, res);
|
|
if (!content) return;
|
|
|
|
const { filename, mime_type, remote_url, folder, folder_id } = req.body;
|
|
const updates = [];
|
|
const values = [];
|
|
if (filename !== undefined) { updates.push('filename = ?'); values.push(safeFilename(filename)); }
|
|
if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); }
|
|
if (remote_url !== undefined) {
|
|
if (remote_url) {
|
|
const urlErr = validateRemoteUrl(remote_url);
|
|
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
|
|
}
|
|
updates.push('remote_url = ?');
|
|
values.push(remote_url || null);
|
|
}
|
|
if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); }
|
|
if (folder_id !== undefined) {
|
|
// Verify the destination folder belongs to the same user. Only superadmin gets
|
|
// cross-user access — matches the policy in routes/folders.js so a plain "admin"
|
|
// can't move content into a folder they can't see in GET /api/folders.
|
|
if (folder_id) {
|
|
const target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id);
|
|
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
|
|
const isSuperadmin = req.user.role === 'superadmin';
|
|
if (!isSuperadmin && target.user_id !== req.user.id) {
|
|
return res.status(403).json({ error: 'Cannot move content to another user\'s folder' });
|
|
}
|
|
}
|
|
updates.push('folder_id = ?');
|
|
values.push(folder_id || null);
|
|
}
|
|
|
|
if (updates.length > 0) {
|
|
values.push(req.params.id);
|
|
db.prepare(`UPDATE content SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
|
|
});
|
|
|
|
// Replace content file
|
|
router.put('/:id/replace', upload.single('file'), async (req, res) => {
|
|
const content = checkContentAccess(req, res);
|
|
if (!content) return;
|
|
if (!req.file) return res.status(400).json({ error: 'No file provided' });
|
|
|
|
// Delete old file
|
|
if (content.filepath) {
|
|
const oldPath = path.join(config.contentDir, content.filepath);
|
|
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
|
}
|
|
// Delete old thumbnail
|
|
if (content.thumbnail_path) {
|
|
const oldThumb = path.join(config.contentDir, content.thumbnail_path);
|
|
if (fs.existsSync(oldThumb)) fs.unlinkSync(oldThumb);
|
|
}
|
|
|
|
const filepath = req.file.filename;
|
|
let width = null, height = null, thumbnailPath = null;
|
|
|
|
// Generate new thumbnail for images
|
|
try {
|
|
if (req.file.mimetype.startsWith('image/')) {
|
|
const sharp = require('sharp');
|
|
const metadata = await sharp(req.file.path).metadata();
|
|
width = metadata.width;
|
|
height = metadata.height;
|
|
thumbnailPath = `thumb_${filepath}`;
|
|
await sharp(req.file.path).resize(config.thumbnailWidth).jpeg({ quality: 70 })
|
|
.toFile(path.join(config.contentDir, thumbnailPath));
|
|
}
|
|
} catch (e) {
|
|
console.warn('Thumbnail generation failed:', e.message);
|
|
}
|
|
|
|
db.prepare(`UPDATE content SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ? WHERE id = ?`)
|
|
.run(filepath, req.file.mimetype, req.file.size, thumbnailPath, width, height, req.params.id);
|
|
|
|
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
|
|
});
|
|
|
|
// Serve content file
|
|
router.get('/:id/file', (req, res) => {
|
|
const content = checkContentAccess(req, res);
|
|
if (!content) return;
|
|
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
|
|
// 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' });
|
|
res.sendFile(safePath);
|
|
});
|
|
|
|
// Serve thumbnail
|
|
router.get('/:id/thumbnail', (req, res) => {
|
|
const content = checkContentAccess(req, res);
|
|
if (!content) return;
|
|
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' });
|
|
res.sendFile(safePath);
|
|
});
|
|
|
|
// Delete content
|
|
router.delete('/:id', (req, res) => {
|
|
const content = checkContentAccess(req, res);
|
|
if (!content) return;
|
|
|
|
// Delete file from disk (skip for remote URL content)
|
|
if (content.filepath) {
|
|
const filePath = path.join(config.contentDir, content.filepath);
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
}
|
|
|
|
// Delete thumbnail
|
|
if (content.thumbnail_path) {
|
|
const thumbPath = path.join(config.contentDir, content.thumbnail_path);
|
|
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
|
|
}
|
|
|
|
// Get devices that have this content in their playlist (via playlist_items)
|
|
const affectedDevices = db.prepare(`
|
|
SELECT DISTINCT d.id as device_id FROM devices d
|
|
JOIN playlists p ON d.playlist_id = p.id
|
|
JOIN playlist_items pi ON pi.playlist_id = p.id
|
|
WHERE pi.content_id = ?
|
|
`).all(req.params.id);
|
|
|
|
// Scrub published snapshots that reference this content
|
|
// Validate UUID format to prevent LIKE wildcard injection
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
if (!UUID_RE.test(req.params.id)) return res.status(400).json({ error: 'Invalid content ID format' });
|
|
const snapshotPlaylists = db.prepare(
|
|
"SELECT id, published_snapshot FROM playlists WHERE user_id = ? AND published_snapshot LIKE ?"
|
|
).all(content.user_id, `%${req.params.id}%`);
|
|
for (const pl of snapshotPlaylists) {
|
|
try {
|
|
const items = JSON.parse(pl.published_snapshot);
|
|
const filtered = items.filter(item => item.content_id !== req.params.id);
|
|
if (filtered.length !== items.length) {
|
|
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?')
|
|
.run(JSON.stringify(filtered), pl.id);
|
|
}
|
|
} catch (e) { /* corrupt snapshot, skip */ }
|
|
}
|
|
|
|
// Delete from DB (cascades to playlist_items via ON DELETE CASCADE)
|
|
db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id);
|
|
|
|
// Push updated snapshots to affected devices
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
for (const d of affectedDevices) {
|
|
io.of('/device').to(d.device_id).emit('device:playlist-update', buildPlaylistPayload(d.device_id));
|
|
}
|
|
}
|
|
} catch (e) { /* silent */ }
|
|
|
|
res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) });
|
|
});
|
|
|
|
module.exports = router;
|