screentinker/server/ws/deviceSocket.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

460 lines
21 KiB
JavaScript

const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
const config = require('../config');
const heartbeat = require('../services/heartbeat');
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
// In-memory store for latest screenshot per device (avoids disk writes during streaming)
let lastScreenshots = {};
// Generate a random device token
function generateDeviceToken() {
return crypto.randomBytes(32).toString('hex');
}
// Validate device_id + device_token pair. Returns true if valid.
function validateDeviceToken(deviceId, token) {
if (!deviceId || !token) return false;
const row = db.prepare('SELECT device_token FROM devices WHERE id = ?').get(deviceId);
if (!row || !row.device_token) return false;
// Constant-time comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(Buffer.from(row.device_token), Buffer.from(token));
} catch {
return false;
}
}
function getClientIp(socket) {
const forwarded = socket.handshake.headers['x-forwarded-for'];
if (forwarded) return forwarded.split(',')[0].trim();
return socket.handshake.address;
}
function logDeviceStatus(deviceId, status) {
try {
db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)').run(deviceId, status);
// Prune entries older than 7 days
db.prepare("DELETE FROM device_status_log WHERE device_id = ? AND timestamp < strftime('%s','now') - 604800").run(deviceId);
} catch (e) { /* table might not exist yet */ }
}
// Build playlist payload with layout and zones
// Reads from published_snapshot (Phase 3) so draft edits don't affect live devices
function buildPlaylistPayload(deviceId) {
const device = db.prepare('SELECT playlist_id, layout_id, orientation FROM devices WHERE id = ?').get(deviceId);
let assignments = [];
if (device?.playlist_id) {
const playlist = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
if (playlist?.published_snapshot) {
try { assignments = JSON.parse(playlist.published_snapshot); } catch (e) { assignments = []; }
}
}
let layout = null;
if (device?.layout_id) {
layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(device.layout_id);
if (layout) {
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id);
}
}
return { assignments, layout, orientation: device?.orientation || 'landscape' };
}
// Check if a device should show trial expired screen
function checkDeviceAccess(deviceId) {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(deviceId);
if (!device || !device.user_id) return { allowed: true };
const plan = getUserPlan(device.user_id);
if (!plan) return { allowed: true };
// Check if trial expired and over free limit
if (plan.trial_started && !plan.trial_active && plan.plan_name === 'free') {
const deviceCount = getUserDeviceCount(device.user_id);
// Get this device's position (ordered by created_at)
const userDevices = db.prepare('SELECT id FROM devices WHERE user_id = ? ORDER BY created_at ASC').all(device.user_id);
const deviceIndex = userDevices.findIndex(d => d.id === deviceId);
// Only the first device (within free limit) is allowed
if (deviceIndex >= plan.max_devices) {
return {
allowed: false,
reason: 'trial_expired',
message: 'Trial Expired',
detail: 'Upgrade your plan to continue using this display.',
};
}
}
// Check if over plan device limit (non-trial)
if (!plan.trial_started && plan.max_devices > 0) {
const userDevices = db.prepare('SELECT id FROM devices WHERE user_id = ? ORDER BY created_at ASC').all(device.user_id);
const deviceIndex = userDevices.findIndex(d => d.id === deviceId);
if (deviceIndex >= plan.max_devices) {
return {
allowed: false,
reason: 'device_limit',
message: 'Device Limit Reached',
detail: 'Upgrade your plan to activate this display.',
};
}
}
return { allowed: true };
}
module.exports = function setupDeviceSocket(io) {
// Expose helpers for use by route handlers
module.exports.lastScreenshots = lastScreenshots;
module.exports.buildPlaylistPayload = buildPlaylistPayload;
module.exports.generateDeviceToken = generateDeviceToken;
const deviceNs = io.of('/device');
const dashboardNs = io.of('/dashboard');
// Disconnect any existing socket that is currently registered for this device_id.
// Called when a fresh registration comes in for the same device so the old (likely
// half-dead) socket can't fire its disconnect handler and clobber the new entry.
function evictPriorSocket(deviceId, exceptSocketId) {
const prior = heartbeat.getConnection(deviceId);
if (!prior || prior.socketId === exceptSocketId) return;
const oldSocket = deviceNs.sockets.get(prior.socketId);
if (oldSocket) {
console.log(`Evicting prior socket ${prior.socketId} for device ${deviceId}`);
try { oldSocket.disconnect(true); } catch (_) {}
}
}
deviceNs.on('connection', (socket) => {
console.log(`Device socket connected: ${socket.id}`);
let currentDeviceId = null;
let authenticated = false; // Track whether this socket has been authenticated
// Device registers with a pairing code (first time) or device_id + device_token (reconnect)
socket.on('device:register', (data) => {
const { pairing_code, device_id, device_token, device_info, fingerprint } = data;
// Track device fingerprint to prevent reinstall abuse
if (fingerprint) {
try {
const existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint);
if (existing) {
db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ? WHERE fingerprint = ?")
.run(device_id || existing.device_id, fingerprint);
// If this fingerprint was previously registered to a different device, block the new registration
if (!device_id && existing.device_id && pairing_code) {
// Someone reinstalled - link them back to existing device
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
if (oldDevice) {
// Fingerprint reclaim guard: a leaked/duplicated fingerprint shouldn't be enough
// to take over a live device. Reject the reclaim if the device is currently
// online OR has been online within the last 24h — by then a real reinstall has
// had plenty of time to come back, but a credential thief is more likely caught.
const liveConn = heartbeat.getConnection(existing.device_id);
const RECLAIM_GRACE_SECONDS = 24 * 60 * 60;
const lastBeat = oldDevice.last_heartbeat || 0;
const secondsSince = Math.floor(Date.now() / 1000) - lastBeat;
if (liveConn || (oldDevice.status === 'online') || secondsSince < RECLAIM_GRACE_SECONDS) {
console.warn(`Fingerprint reclaim rejected for ${existing.device_id}: device active (status=${oldDevice.status}, ${secondsSince}s since last heartbeat, liveConn=${!!liveConn})`);
socket.emit('device:auth-error', {
error: 'This display is currently active. If you reinstalled the app, the original device must be offline for 24 hours before its slot can be reclaimed.'
});
return;
}
// Fingerprint matched — this is a reinstalled app reconnecting to its old device.
// Issue a fresh token so the app can authenticate going forward.
const newToken = generateDeviceToken();
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
authenticated = true;
evictPriorSocket(existing.device_id, socket.id);
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(getClientIp(socket), existing.device_id);
socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' });
// If device was already claimed by a user, tell the player it's paired
if (oldDevice.user_id) {
socket.emit('device:paired', { name: oldDevice.name || 'Display' });
}
currentDeviceId = existing.device_id;
heartbeat.registerConnection(existing.device_id, socket.id);
socket.join(existing.device_id);
logDeviceStatus(existing.device_id, 'online');
dashboardNs.emit('dashboard:device-status', { device_id: existing.device_id, status: 'online' });
// Send playlist
const access = checkDeviceAccess(existing.device_id);
if (!access.allowed) {
socket.emit('device:playlist-update', { assignments: [], suspended: true, message: access.message, detail: access.detail });
} else {
socket.emit('device:playlist-update', buildPlaylistPayload(existing.device_id));
}
return;
}
}
} else if (device_id || pairing_code) {
db.prepare("INSERT OR IGNORE INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)")
.run(fingerprint, device_id || null);
}
} catch (e) {
console.error('Fingerprint tracking error:', e.message);
}
}
if (device_id) {
// Reconnecting known device — require valid token
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
if (device) {
// Validate device token (skip for legacy devices that don't have a token yet)
if (device.device_token && !validateDeviceToken(device_id, device_token)) {
console.warn(`Invalid device token for ${device_id} from ${getClientIp(socket)} — received_len=${(device_token || '').length}, stored_len=${device.device_token.length}, received_prefix=${(device_token || '').substring(0, 8)}, stored_prefix=${device.device_token.substring(0, 8)}`);
socket.emit('device:auth-error', { error: 'Invalid device token' });
return;
}
currentDeviceId = device_id;
authenticated = true;
evictPriorSocket(device_id, socket.id);
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(getClientIp(socket), device_id);
// Generate token for legacy devices that don't have one yet
let tokenToSend = device.device_token;
if (!tokenToSend) {
tokenToSend = generateDeviceToken();
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(tokenToSend, device_id);
}
if (device_info) {
db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE id = ?')
.run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_id);
}
heartbeat.registerConnection(device_id, socket.id);
socket.join(device_id);
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
logDeviceStatus(device_id, 'online');
// Check subscription/trial status before sending playlist
const access = checkDeviceAccess(device_id);
if (!access.allowed) {
socket.emit('device:playlist-update', { assignments: [], suspended: true, message: access.message, detail: access.detail });
} else {
socket.emit('device:playlist-update', buildPlaylistPayload(device_id));
}
dashboardNs.emit('dashboard:device-status', { device_id, status: 'online' });
console.log(`Device reconnected: ${device_id}`);
return;
}
// Device ID not found in database - tell device to re-provision
console.log(`Device ${device_id} not found in database, sending unpaired`);
socket.emit('device:unpaired', { reason: 'not_found' });
return;
}
if (pairing_code) {
// New device registering with pairing code — generate a device_token
const id = uuidv4();
const newToken = generateDeviceToken();
currentDeviceId = id;
authenticated = true;
db.prepare(`
INSERT INTO devices (id, pairing_code, device_token, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat)
VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now'))
`).run(
id, pairing_code, newToken, getClientIp(socket),
device_info?.android_version || null,
device_info?.app_version || null,
device_info?.screen_width || null,
device_info?.screen_height || null
);
heartbeat.registerConnection(id, socket.id);
socket.join(id);
socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' });
dashboardNs.emit('dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id));
console.log(`New device registered: ${id} with pairing code: ${pairing_code}`);
}
});
// Require authentication for all events after register
function requireDeviceAuth() {
if (!authenticated || !currentDeviceId) {
socket.emit('device:auth-error', { error: 'Not authenticated. Send device:register first.' });
return false;
}
return true;
}
// Heartbeat with telemetry
socket.on('device:heartbeat', (data) => {
if (!requireDeviceAuth()) return;
const { device_id, telemetry } = data;
if (!device_id || device_id !== currentDeviceId) return;
currentDeviceId = device_id;
heartbeat.updateHeartbeat(device_id);
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), updated_at = strftime('%s','now') WHERE id = ?")
.run(device_id);
if (telemetry) {
db.prepare(`
INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb,
ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
device_id,
telemetry.battery_level ?? null,
telemetry.battery_charging ? 1 : 0,
telemetry.storage_free_mb ?? null,
telemetry.storage_total_mb ?? null,
telemetry.ram_free_mb ?? null,
telemetry.ram_total_mb ?? null,
telemetry.cpu_usage ?? null,
telemetry.wifi_ssid ?? null,
telemetry.wifi_rssi ?? null,
telemetry.uptime_seconds ?? null
);
pruneTelemetry(device_id);
dashboardNs.emit('dashboard:device-status', {
device_id,
status: 'online',
telemetry
});
}
});
// Screenshot received from device - relay via WebSocket, keep latest in memory
socket.on('device:screenshot', (data) => {
if (!requireDeviceAuth()) return;
const { device_id, image_b64 } = data;
if (!device_id || device_id !== currentDeviceId || !image_b64) return;
// Validate screenshot size (max 2MB base64 ≈ 1.5MB image)
if (image_b64.length > 2 * 1024 * 1024) return;
// Store latest screenshot in memory (for Now Playing preview and offline snapshot)
if (!lastScreenshots) lastScreenshots = {};
lastScreenshots[device_id] = image_b64;
// Relay directly to dashboard - no disk write
try {
dashboardNs.emit('dashboard:screenshot-ready', {
device_id,
image_data: `data:image/jpeg;base64,${image_b64}`,
timestamp: Date.now()
});
} catch (err) {
console.error('Screenshot save error:', err);
}
});
// Content download acknowledgement
socket.on('device:content-ack', (data) => {
if (!requireDeviceAuth()) return;
const { device_id, content_id, status } = data;
if (device_id !== currentDeviceId) return;
console.log(`Device ${device_id} content ${content_id}: ${status}`);
dashboardNs.emit('dashboard:content-ack', { device_id, content_id, status });
});
// Playback state update
socket.on('device:playback-state', (data) => {
if (!requireDeviceAuth()) return;
dashboardNs.emit('dashboard:playback-state', data);
});
// Play event logging (proof-of-play)
socket.on('device:play-event', (data) => {
if (!requireDeviceAuth()) return;
const { device_id, event, content_id, content_name, zone_id, completed } = data;
if (device_id !== currentDeviceId) return;
try {
if (event === 'play_start') {
db.prepare(`
INSERT INTO play_logs (device_id, content_id, zone_id, content_name, started_at, trigger_type)
VALUES (?, ?, ?, ?, strftime('%s','now'), 'playlist')
`).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown');
} else if (event === 'play_end') {
db.prepare(`
UPDATE play_logs SET ended_at = strftime('%s','now'),
duration_sec = strftime('%s','now') - started_at,
completed = ?
WHERE id = (
SELECT id FROM play_logs WHERE device_id = ? AND content_id = ? AND ended_at IS NULL
ORDER BY started_at DESC LIMIT 1
)
`).run(completed ? 1 : 0, device_id, content_id);
}
} catch (err) {
console.error('Play log error:', err.message);
}
});
// Video wall sync relay
socket.on('wall:sync', (data) => {
if (!requireDeviceAuth()) return;
// Relay to all devices in the same wall
const wallDevices = db.prepare(
'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?'
).all(data.wall_id, data.device_id);
for (const wd of wallDevices) {
deviceNs.to(wd.device_id).emit('wall:sync', data);
}
});
socket.on('disconnect', () => {
if (currentDeviceId) {
// If a newer socket has already taken over this device_id, this is a stale
// disconnect from a replaced socket — skip the offline transition so we don't
// flip an actively-connected device offline or clobber the new heartbeat entry.
const activeConn = heartbeat.getConnection(currentDeviceId);
if (activeConn && activeConn.socketId !== socket.id) {
console.log(`Stale disconnect for ${currentDeviceId} (socket ${socket.id}); active is ${activeConn.socketId}, skipping offline`);
return;
}
console.log(`Device disconnected: ${currentDeviceId}`);
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?")
.run(currentDeviceId);
heartbeat.removeConnection(currentDeviceId);
logDeviceStatus(currentDeviceId, 'offline');
dashboardNs.emit('dashboard:device-status', { device_id: currentDeviceId, status: 'offline' });
// Save last screenshot to disk as offline snapshot
const lastB64 = lastScreenshots[currentDeviceId];
if (lastB64) {
try {
const filename = `${currentDeviceId}_latest.jpg`;
const buffer = Buffer.from(lastB64, 'base64');
fs.writeFileSync(path.join(config.screenshotsDir, filename), buffer);
// Upsert screenshot record
const existing = db.prepare('SELECT id FROM screenshots WHERE device_id = ?').get(currentDeviceId);
if (existing) {
db.prepare('UPDATE screenshots SET filepath = ?, captured_at = strftime(\'%s\',\'now\') WHERE device_id = ?')
.run(filename, currentDeviceId);
} else {
db.prepare('INSERT INTO screenshots (device_id, filepath) VALUES (?, ?)').run(currentDeviceId, filename);
}
} catch (e) {
console.error('Failed to save offline screenshot:', e.message);
}
delete lastScreenshots[currentDeviceId];
}
}
});
});
return deviceNs;
};