Security audit remediation: auth, IDOR, XSS, hardening

- Device WebSocket authentication: devices get a device_token on
  registration, must present it on reconnect. All WS events require
  prior auth. Timing-safe token comparison.
- IDOR fixes: ownership checks on schedules (device, week), layouts
  (all CRUD, zones, duplicate, device assign), video-walls (content,
  device-config).
- XSS prevention: shared esc() helper in utils.js, fixed 13 innerHTML
  injection points across 9 frontend files.
- OAuth hardening: no longer silently overwrites auth_provider on
  accounts with local passwords (returns 409).
- JWT pinned to HS256 for sign and verify.
- Password policy: change endpoint now requires 8 chars (was 6).
- HSTS header enabled (max-age 1 year, includeSubDomains).
- Stripe webhook rejects unsigned payloads when no secret configured.
- Screenshot size validation (max 2MB base64).
- Rate limiting on exports, imports, content operations.
- Content file serving checks playlist_items instead of old assignments.
- Content ownership verified in device-groups assign-content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-11 22:48:07 -05:00
parent b87904c326
commit afbe113acf
21 changed files with 186 additions and 62 deletions

5
frontend/js/utils.js Normal file
View file

@ -0,0 +1,5 @@
// HTML escape helper — prevents XSS when inserting user data into innerHTML
export function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}

View file

@ -1,4 +1,5 @@
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
@ -39,10 +40,10 @@ export async function render(container) {
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-card);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:14px">${icon}</div>
<div style="flex:1;min-width:0">
<div style="font-size:13px">
<strong>${item.user_name || item.user_email || 'System'}</strong>
<span style="color:var(--text-secondary)"> ${formatAction(item.action)}</span>
<strong>${esc(item.user_name || item.user_email || 'System')}</strong>
<span style="color:var(--text-secondary)"> ${esc(formatAction(item.action))}</span>
</div>
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${item.details}</div>` : ''}
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(item.details)}</div>` : ''}
</div>
<div style="font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0">${timeStr}</div>
</div>

View file

@ -1,5 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
@ -117,7 +118,7 @@ async function loadUsers() {
setTimeout(() => { confirming = false; btn.textContent = 'Remove'; btn.style.background = ''; btn.style.color = ''; }, 3000);
};
});
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
}
async function loadPlans() {
@ -146,7 +147,7 @@ async function loadPlans() {
</tbody>
</table>
`;
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
}
async function loadSystem() {
@ -164,7 +165,7 @@ async function loadSystem() {
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">Server Status</a>
</div>
`;
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
}
export function cleanup() {}

View file

@ -1,5 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
export async function render(container) {
container.innerHTML = `
@ -140,7 +141,7 @@ export async function render(container) {
}
} catch (err) {
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${err.message}</p></div>`;
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${esc(err.message)}</p></div>`;
}
}

View file

@ -1,5 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
function formatFileSize(bytes) {
if (!bytes) return '--';
@ -355,7 +356,7 @@ async function loadContent() {
};
} catch (err) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${err.message}</p></div>`;
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${esc(err.message)}</p></div>`;
}
}

View file

@ -1,11 +1,7 @@
import { api } from '../api.js';
import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.js';
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');
}
import { esc } from '../utils.js';
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
const GROUP_COMMANDS = [
@ -357,7 +353,7 @@ async function loadDashboard() {
attachGroupHandlers(groupsWithDevices, devices);
} catch (err) {
main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</h3><p>${err.message}</p></div>`;
main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</h3><p>${esc(err.message)}</p></div>`;
}
}

View file

@ -1,6 +1,7 @@
import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
let currentDevice = null;
let statusHandler = null;
@ -449,7 +450,7 @@ async function loadDevice(deviceId, activeTab = null) {
}
} catch (err) {
contentEl.innerHTML = `<div class="empty-state"><h3>Failed to load device</h3><p>${err.message}</p></div>`;
contentEl.innerHTML = `<div class="empty-state"><h3>Failed to load device</h3><p>${esc(err.message)}</p></div>`;
}
}

View file

@ -1,12 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
// Escape user-controlled strings for safe HTML interpolation
function esc(str) {
const d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
import { esc } from '../utils.js';
function formatDate(ts) {
if (!ts) return '--';

View file

@ -1,5 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -158,7 +159,7 @@ export async function render(container) {
renderBarChart('hourlyChart', hourData);
} catch (err) {
content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${err.message}</p></div>`;
content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${esc(err.message)}</p></div>`;
}
}
}

View file

@ -1,6 +1,7 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js';
import { esc } from '../utils.js';
export async function render(container) {
const serverUrl = `${window.location.protocol}//${window.location.host}`;
@ -180,7 +181,7 @@ export async function render(container) {
if (isZip) {
// For ZIP, show basic info and skip preview parsing
data = { format: 'screentinker-export-v1', _isZip: true };
statusEl.innerHTML = `ZIP export detected: <strong>${file.name}</strong> (${(file.size / 1048576).toFixed(1)} MB)<br>Contains data + media files.<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
statusEl.innerHTML = `ZIP export detected: <strong>${esc(file.name)}</strong> (${(file.size / 1048576).toFixed(1)} MB)<br>Contains data + media files.<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
} else {
const text = await file.text();
data = JSON.parse(text);
@ -198,7 +199,7 @@ export async function render(container) {
data.video_walls?.length ? `${data.video_walls.length} video walls` : null,
data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null,
].filter(Boolean).join(', ');
statusEl.innerHTML = `Found: ${summary || 'empty export'}.<br>From: ${data.user?.email || 'unknown'} (exported ${data.exported_at?.split('T')[0] || 'unknown'})<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
statusEl.innerHTML = `Found: ${esc(summary) || 'empty export'}.<br>From: ${esc(data.user?.email) || 'unknown'} (exported ${esc(data.exported_at?.split('T')[0]) || 'unknown'})<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
}
document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; };
document.getElementById('confirmImportBtn').onclick = async () => {
@ -413,7 +414,7 @@ async function loadUsers() {
});
} catch (err) {
el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`;
el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`;
}
}

View file

@ -58,6 +58,8 @@ const migrations = [
"ALTER TABLE devices ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
"ALTER TABLE schedules ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
"ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0",
// Device authentication token
"ALTER TABLE devices ADD COLUMN device_token TEXT",
];
for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ }

View file

@ -382,6 +382,15 @@ CREATE TABLE IF NOT EXISTS kiosk_pages (
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- ===================== DEVICE STATUS LOG =====================
CREATE TABLE IF NOT EXISTS device_status_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
status TEXT NOT NULL,
timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- ===================== DEVICE FINGERPRINTS =====================
CREATE TABLE IF NOT EXISTS device_fingerprints (

View file

@ -6,12 +6,12 @@ function generateToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role },
config.jwtSecret,
{ expiresIn: config.jwtExpiry }
{ algorithm: 'HS256', expiresIn: config.jwtExpiry }
);
}
function verifyToken(token) {
return jwt.verify(token, config.jwtSecret);
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
}
// Express middleware - requires valid JWT

View file

@ -108,7 +108,12 @@ router.post('/google', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
} else if (user.auth_provider !== 'google') {
// Link Google to existing account
// Existing account with different provider — do NOT silently overwrite auth_provider.
// If they have a local password, require them to log in locally and link from settings.
if (user.password_hash) {
return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' });
}
// No password (e.g. Microsoft → Google switch) — allow linking
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?')
.run('google', googleId, picture || user.avatar_url, user.id);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
@ -178,6 +183,10 @@ router.post('/microsoft', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
} else if (user.auth_provider !== 'microsoft') {
// Existing account with different provider — do NOT silently overwrite auth_provider.
if (user.password_hash) {
return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' });
}
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?')
.run('microsoft', microsoftId, user.id);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
@ -223,7 +232,7 @@ router.put('/me', requireAuth, (req, res) => {
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(name, req.user.id);
}
if (password && password.length >= 6) {
if (password && password.length >= 8) {
const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(hash, req.user.id);

View file

@ -109,6 +109,10 @@ router.post('/:id/assign-content', requireGroupOwnership, (req, res) => {
const { content_id, duration_sec } = req.body;
if (!content_id) return res.status(400).json({ error: 'content_id required' });
// Verify content belongs to the user
const content = db.prepare('SELECT id FROM content WHERE id = ? AND user_id = ?').get(content_id, req.user.id);
if (!content) return res.status(404).json({ error: 'Content not found' });
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
const transaction = db.transaction(() => {

View file

@ -24,10 +24,20 @@ router.get('/', (req, res) => {
res.json(layouts);
});
// Helper: check layout access (owner, admin, or template)
function checkLayoutAccess(req, res) {
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; }
if (!layout.is_template && !['admin','superadmin'].includes(req.user.role) && layout.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return null;
}
return layout;
}
// Get layout with zones
router.get('/:id', (req, res) => {
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!layout) return res.status(404).json({ error: 'Layout not found' });
const layout = checkLayoutAccess(req, res);
if (!layout) return;
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id);
res.json(layout);
@ -62,8 +72,8 @@ router.post('/', (req, res) => {
// Update layout
router.put('/:id', (req, res) => {
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!layout) return res.status(404).json({ error: 'Layout not found' });
const layout = checkLayoutAccess(req, res);
if (!layout) return;
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' });
const { name, width, height } = req.body;
@ -78,8 +88,8 @@ router.put('/:id', (req, res) => {
// Delete layout
router.delete('/:id', (req, res) => {
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!layout) return res.status(404).json({ error: 'Layout not found' });
const layout = checkLayoutAccess(req, res);
if (!layout) return;
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' });
db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id);
@ -88,8 +98,8 @@ router.delete('/:id', (req, res) => {
// Add zone to layout
router.post('/:id/zones', (req, res) => {
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!layout) return res.status(404).json({ error: 'Layout not found' });
const layout = checkLayoutAccess(req, res);
if (!layout) return;
const { name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color } = req.body;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM layout_zones WHERE layout_id = ?').get(req.params.id).m || 0;
@ -110,6 +120,8 @@ router.post('/:id/zones', (req, res) => {
// Update zone
router.put('/:id/zones/:zoneId', (req, res) => {
const layout = checkLayoutAccess(req, res);
if (!layout) return;
const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ? AND layout_id = ?').get(req.params.zoneId, req.params.id);
if (!zone) return res.status(404).json({ error: 'Zone not found' });
@ -132,6 +144,8 @@ router.put('/:id/zones/:zoneId', (req, res) => {
// Delete zone
router.delete('/:id/zones/:zoneId', (req, res) => {
const layout = checkLayoutAccess(req, res);
if (!layout) return;
db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(req.params.zoneId, req.params.id);
db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
@ -139,8 +153,8 @@ router.delete('/:id/zones/:zoneId', (req, res) => {
// Duplicate layout (for using templates)
router.post('/:id/duplicate', (req, res) => {
const source = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!source) return res.status(404).json({ error: 'Layout not found' });
const source = checkLayoutAccess(req, res);
if (!source) return;
const newId = uuidv4();
const name = req.body.name || `${source.name} (Copy)`;
@ -166,6 +180,9 @@ router.post('/:id/duplicate', (req, res) => {
// Assign layout to device
router.put('/device/:deviceId', (req, res) => {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const { layout_id } = req.body;
db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(layout_id || null, req.params.deviceId);

View file

@ -17,8 +17,12 @@ router.get('/', (req, res) => {
res.json(db.prepare(sql).all(...params));
});
// Get schedules for a device
// Get schedules for a device (verify device belongs to user)
router.get('/device/:deviceId', (req, res) => {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const schedules = db.prepare(`
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
FROM schedules s
@ -36,6 +40,11 @@ router.get('/week', (req, res) => {
const { date, device_id } = req.query;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
// Verify device ownership
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const weekStart = date ? new Date(date) : new Date();
weekStart.setHours(0, 0, 0, 0);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());

View file

@ -91,11 +91,11 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
let event;
try {
if (config.stripeWebhookSecret) {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret);
} else {
event = JSON.parse(req.body.toString());
if (!config.stripeWebhookSecret) {
console.error('Stripe webhook secret not configured — rejecting unsigned webhook');
return res.status(400).json({ error: 'Webhook secret not configured' });
}
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).json({ error: 'Invalid signature' });

View file

@ -143,6 +143,8 @@ router.put('/:id/devices', (req, res) => {
// Set wall content
router.put('/:id/content', (req, res) => {
const wall = checkWallAccess(req, res);
if (!wall) return;
const { content_id } = req.body;
db.prepare("UPDATE video_walls SET content_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(content_id || null, req.params.id);
@ -151,8 +153,8 @@ router.put('/:id/content', (req, res) => {
// Get wall config for a specific device (used by Android app)
router.get('/:id/device-config/:deviceId', (req, res) => {
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id);
if (!wall) return res.status(404).json({ error: 'Wall not found' });
const wall = checkWallAccess(req, res);
if (!wall) return;
const position = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?')
.get(req.params.id, req.params.deviceId);

View file

@ -39,6 +39,7 @@ const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: false, // Allow inline scripts in widget renders
crossOriginEmbedderPolicy: false, // Allow loading external widget content
hsts: { maxAge: 31536000, includeSubDomains: true },
}));
// CORS: open for public content (kiosk, widgets, player, uploads), restricted for API
app.use(cors({
@ -123,6 +124,10 @@ app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minut
app.use('/api/auth', require('./routes/auth'));
// Rate limit pairing to prevent brute force (5 attempts per minute per IP)
app.use('/api/provision/pair', rateLimit(60000, 5));
// Rate limit expensive operations
app.use('/api/status/export', rateLimit(60000, 5)); // 5 exports per minute
app.use('/api/status/import', rateLimit(60000, 3)); // 3 imports per minute
app.use('/api/content', rateLimit(60000, 30)); // 30 content operations per minute
// Subscription routes (mixed auth)
app.use('/api/subscription', require('./routes/subscription'));
@ -148,7 +153,7 @@ app.get('/api/devices/:id/screenshot', (req, res) => {
const { db: sdb } = require('./db/database');
const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (user.role !== 'admin' && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' });
if (!['admin','superadmin'].includes(user.role) && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' });
// Serve from memory if available (device online), otherwise from disk (offline snapshot)
const deviceSocket = require('./ws/deviceSocket');
const memScreenshot = deviceSocket.lastScreenshots?.[req.params.id];
@ -171,8 +176,8 @@ 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 assignments WHERE content_id = ? LIMIT 1').get(req.params.id);
if (!assigned) return res.status(403).json({ error: 'Content not assigned to any device' });
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 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);

View file

@ -1,4 +1,5 @@
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
@ -9,6 +10,24 @@ 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();
@ -102,16 +121,18 @@ 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');
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 (reconnect)
// 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_info, fingerprint } = data;
const { pairing_code, device_id, device_token, device_info, fingerprint } = data;
// Track device fingerprint to prevent reinstall abuse
if (fingerprint) {
@ -125,8 +146,16 @@ module.exports = function setupDeviceSocket(io) {
// Someone reinstalled - link them back to existing device
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
if (oldDevice) {
// Validate token if the old device has one
if (oldDevice.device_token && !validateDeviceToken(existing.device_id, device_token)) {
console.warn(`Fingerprint match but invalid token for device ${existing.device_id}`);
// Generate a new token — the reinstalled app needs a fresh one via re-pairing
socket.emit('device:unpaired', { reason: 'invalid_token' });
return;
}
console.log(`Fingerprint match: linking to existing device ${existing.device_id}`);
socket.emit('device:registered', { device_id: existing.device_id, status: oldDevice.status });
authenticated = true;
socket.emit('device:registered', { device_id: existing.device_id, device_token: oldDevice.device_token, status: oldDevice.status });
currentDeviceId = existing.device_id;
heartbeat.registerConnection(existing.device_id, socket.id);
socket.join(existing.device_id);
@ -150,13 +179,28 @@ module.exports = function setupDeviceSocket(io) {
}
if (device_id) {
// Reconnecting known device
// 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)}`);
socket.emit('device:auth-error', { error: 'Invalid device token' });
return;
}
currentDeviceId = device_id;
authenticated = true;
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);
@ -164,7 +208,7 @@ module.exports = function setupDeviceSocket(io) {
heartbeat.registerConnection(device_id, socket.id);
socket.join(device_id);
socket.emit('device:registered', { device_id, status: 'online' });
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
logDeviceStatus(device_id, 'online');
// Check subscription/trial status before sending playlist
@ -187,15 +231,17 @@ module.exports = function setupDeviceSocket(io) {
}
if (pairing_code) {
// New device registering with 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, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat)
VALUES (?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now'))
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, getClientIp(socket),
id, pairing_code, newToken, getClientIp(socket),
device_info?.android_version || null,
device_info?.app_version || null,
device_info?.screen_width || null,
@ -204,17 +250,27 @@ module.exports = function setupDeviceSocket(io) {
heartbeat.registerConnection(id, socket.id);
socket.join(id);
socket.emit('device:registered', { device_id: id, status: 'provisioning' });
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) return;
if (!device_id || device_id !== currentDeviceId) return;
currentDeviceId = device_id;
heartbeat.updateHeartbeat(device_id);
@ -252,8 +308,11 @@ module.exports = function setupDeviceSocket(io) {
// 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 || !image_b64) return;
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 = {};
@ -273,19 +332,24 @@ module.exports = function setupDeviceSocket(io) {
// 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(`
@ -310,6 +374,7 @@ module.exports = function setupDeviceSocket(io) {
// 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 != ?'