Security hardening: auth checks, XSS escaping, input validation

- Add requireGroupOwnership middleware to all group endpoints
- Whitelist allowed command types (screen_on/off, launch, update, reboot, shutdown)
- Validate color format as #RRGGBB
- Escape all user-controlled strings (device/group names, emails) in dashboard HTML
- Restrict trust proxy to first hop only (prevents IP spoofing + rate limit bypass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-09 22:09:40 -05:00
parent faa437881f
commit f57fc5ad81
3 changed files with 39 additions and 24 deletions

View file

@ -2,6 +2,11 @@ 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;');
}
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
const GROUP_COMMANDS = [
{ type: 'screen_on', label: 'Screen On' },
@ -61,12 +66,12 @@ function renderDeviceCard(device) {
</div>` : ''}
</div>
<div class="device-card-body">
<div class="device-card-name">${device.name}</div>
<div class="device-card-name">${esc(device.name)}</div>
${device.owner_name || device.owner_email ? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
${device.owner_name || device.owner_email}
${esc(device.owner_name || device.owner_email)}
</div>` : ''}
<div class="device-card-meta">
<div class="meta-item">
@ -108,14 +113,14 @@ function renderGroupSection(group, devices) {
const onlineCount = devices.filter(d => d.status === 'online').length;
return `
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${group.color || '#3B82F6'}">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}">
<div style="display:flex;align-items:center;gap:10px">
<strong style="font-size:15px">${group.name}</strong>
<strong style="font-size:15px">${esc(group.name)}</strong>
<span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} &middot; ${onlineCount} online</span>
</div>
<div style="display:flex;gap:6px;align-items:center">
${devices.length > 0 ? `
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${group.name}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
<option value="">Send Command...</option>
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')}
</select>
@ -412,7 +417,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000';
modal.innerHTML = `
<div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto">
<h3 style="margin:0 0 4px">${group.name}</h3>
<h3 style="margin:0 0 4px">${esc(group.name)}</h3>
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">Check devices to add them to this group</p>
<div style="display:flex;flex-direction:column;gap:6px">
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
@ -421,8 +426,8 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;background:var(--bg-secondary)">
<input type="checkbox" data-device-id="${d.id}" data-in-groups="${inOther.join(',')}" ${memberIds.has(d.id) ? 'checked' : ''}>
<span class="status-dot ${d.status}" style="width:8px;height:8px"></span>
<span style="font-size:13px;flex:1">${d.name}</span>
${inOther.length > 0 ? `<span style="font-size:10px;color:var(--text-muted);background:var(--bg-primary);padding:1px 6px;border-radius:8px">${inOther.join(', ')}</span>` : ''}
<span style="font-size:13px;flex:1">${esc(d.name)}</span>
${inOther.length > 0 ? `<span style="font-size:10px;color:var(--text-muted);background:var(--bg-primary);padding:1px 6px;border-radius:8px">${esc(inOther.join(', '))}</span>` : ''}
</label>
`;
}).join('')}

View file

@ -3,6 +3,17 @@ const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown'];
// Verify group belongs to the authenticated user
function requireGroupOwnership(req, res, next) {
const group = db.prepare('SELECT * FROM device_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!group) return res.status(404).json({ error: 'group not found' });
req.group = group;
next();
}
// List groups
router.get('/', (req, res) => {
const groups = db.prepare(`
@ -20,6 +31,7 @@ router.get('/', (req, res) => {
router.post('/', (req, res) => {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'name required' });
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
const id = uuidv4();
db.prepare('INSERT INTO device_groups (id, user_id, name, color) VALUES (?, ?, ?, ?)')
.run(id, req.user.id, name, color || '#3B82F6');
@ -27,21 +39,22 @@ router.post('/', (req, res) => {
});
// Update group
router.put('/:id', (req, res) => {
router.put('/:id', requireGroupOwnership, (req, res) => {
const { name, color } = req.body;
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ? AND user_id = ?').run(name, req.params.id, req.user.id);
if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ? AND user_id = ?').run(color, req.params.id, req.user.id);
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ?').run(color, req.params.id);
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
});
// Delete group
router.delete('/:id', (req, res) => {
db.prepare('DELETE FROM device_groups WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
router.delete('/:id', requireGroupOwnership, (req, res) => {
db.prepare('DELETE FROM device_groups WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Get devices in a group
router.get('/:id/devices', (req, res) => {
router.get('/:id/devices', requireGroupOwnership, (req, res) => {
const devices = db.prepare(`
SELECT d.* FROM devices d
JOIN device_group_members dgm ON d.id = dgm.device_id
@ -52,7 +65,7 @@ router.get('/:id/devices', (req, res) => {
});
// Add device to group
router.post('/:id/devices', (req, res) => {
router.post('/:id/devices', requireGroupOwnership, (req, res) => {
const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
try {
@ -64,13 +77,13 @@ router.post('/:id/devices', (req, res) => {
});
// Remove device from group
router.delete('/:id/devices/:deviceId', (req, res) => {
router.delete('/:id/devices/:deviceId', requireGroupOwnership, (req, res) => {
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(req.params.deviceId, req.params.id);
res.json({ success: true });
});
// Bulk assign content to all devices in a group
router.post('/:id/assign-content', (req, res) => {
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' });
@ -86,13 +99,10 @@ router.post('/:id/assign-content', (req, res) => {
});
// Send command to all devices in a group
router.post('/:id/command', (req, res) => {
router.post('/:id/command', requireGroupOwnership, (req, res) => {
const { type, payload } = req.body;
if (!type) return res.status(400).json({ error: 'command type required' });
// Verify group belongs to user
const group = db.prepare('SELECT * FROM device_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!group) return res.status(404).json({ error: 'group not found' });
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
const devices = db.prepare(`
SELECT d.id, d.name, d.status FROM devices d
@ -115,7 +125,7 @@ router.post('/:id/command', (req, res) => {
const sent = results.filter(r => r.status === 'sent').length;
const offline = results.filter(r => r.status === 'offline').length;
console.log(`Group command '${type}' sent to group '${group.name}': ${sent} sent, ${offline} offline`);
console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline`);
res.json({ success: true, sent, offline, total: devices.length, results });
});

View file

@ -13,7 +13,7 @@ const config = require('./config');
});
const app = express();
app.set('trust proxy', true);
app.set('trust proxy', 1);
// Determine if SSL certs are available
const hasSsl = fs.existsSync(config.sslCert) && fs.existsSync(config.sslKey);