mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
faa437881f
commit
f57fc5ad81
|
|
@ -2,6 +2,11 @@ import { api } from '../api.js';
|
||||||
import { on, off, requestScreenshot } from '../socket.js';
|
import { on, off, requestScreenshot } from '../socket.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
||||||
const GROUP_COMMANDS = [
|
const GROUP_COMMANDS = [
|
||||||
{ type: 'screen_on', label: 'Screen On' },
|
{ type: 'screen_on', label: 'Screen On' },
|
||||||
|
|
@ -61,12 +66,12 @@ function renderDeviceCard(device) {
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="device-card-body">
|
<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">
|
${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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
${device.owner_name || device.owner_email}
|
${esc(device.owner_name || device.owner_email)}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<div class="device-card-meta">
|
<div class="device-card-meta">
|
||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
|
|
@ -108,14 +113,14 @@ function renderGroupSection(group, devices) {
|
||||||
const onlineCount = devices.filter(d => d.status === 'online').length;
|
const onlineCount = devices.filter(d => d.status === 'online').length;
|
||||||
return `
|
return `
|
||||||
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
|
<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">
|
<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' : ''} · ${onlineCount} online</span>
|
<span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} · ${onlineCount} online</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px;align-items:center">
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
${devices.length > 0 ? `
|
${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>
|
<option value="">Send Command...</option>
|
||||||
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')}
|
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')}
|
||||||
</select>
|
</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.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 = `
|
modal.innerHTML = `
|
||||||
<div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto">
|
<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>
|
<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">
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
|
${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)">
|
<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' : ''}>
|
<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 class="status-dot ${d.status}" style="width:8px;height:8px"></span>
|
||||||
<span style="font-size:13px;flex:1">${d.name}</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">${inOther.join(', ')}</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>
|
</label>
|
||||||
`;
|
`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,17 @@ const router = express.Router();
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { db } = require('../db/database');
|
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
|
// List groups
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const groups = db.prepare(`
|
const groups = db.prepare(`
|
||||||
|
|
@ -20,6 +31,7 @@ router.get('/', (req, res) => {
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
const { name, color } = req.body;
|
const { name, color } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'name required' });
|
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();
|
const id = uuidv4();
|
||||||
db.prepare('INSERT INTO device_groups (id, user_id, name, color) VALUES (?, ?, ?, ?)')
|
db.prepare('INSERT INTO device_groups (id, user_id, name, color) VALUES (?, ?, ?, ?)')
|
||||||
.run(id, req.user.id, name, color || '#3B82F6');
|
.run(id, req.user.id, name, color || '#3B82F6');
|
||||||
|
|
@ -27,21 +39,22 @@ router.post('/', (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update group
|
// Update group
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', requireGroupOwnership, (req, res) => {
|
||||||
const { name, color } = req.body;
|
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 && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
||||||
if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ? AND user_id = ?').run(color, req.params.id, req.user.id);
|
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));
|
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete group
|
// Delete group
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', requireGroupOwnership, (req, res) => {
|
||||||
db.prepare('DELETE FROM device_groups WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
db.prepare('DELETE FROM device_groups WHERE id = ?').run(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get devices in a group
|
// Get devices in a group
|
||||||
router.get('/:id/devices', (req, res) => {
|
router.get('/:id/devices', requireGroupOwnership, (req, res) => {
|
||||||
const devices = db.prepare(`
|
const devices = db.prepare(`
|
||||||
SELECT d.* FROM devices d
|
SELECT d.* FROM devices d
|
||||||
JOIN device_group_members dgm ON d.id = dgm.device_id
|
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
|
// Add device to group
|
||||||
router.post('/:id/devices', (req, res) => {
|
router.post('/:id/devices', requireGroupOwnership, (req, res) => {
|
||||||
const { device_id } = req.body;
|
const { device_id } = req.body;
|
||||||
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
||||||
try {
|
try {
|
||||||
|
|
@ -64,13 +77,13 @@ router.post('/:id/devices', (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove device from group
|
// 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);
|
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(req.params.deviceId, req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bulk assign content to all devices in a group
|
// 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;
|
const { content_id, duration_sec } = req.body;
|
||||||
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
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
|
// 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;
|
const { type, payload } = req.body;
|
||||||
if (!type) return res.status(400).json({ error: 'command type required' });
|
if (!type) return res.status(400).json({ error: 'command type required' });
|
||||||
|
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
|
||||||
// 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' });
|
|
||||||
|
|
||||||
const devices = db.prepare(`
|
const devices = db.prepare(`
|
||||||
SELECT d.id, d.name, d.status FROM devices d
|
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 sent = results.filter(r => r.status === 'sent').length;
|
||||||
const offline = results.filter(r => r.status === 'offline').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 });
|
res.json({ success: true, sent, offline, total: devices.length, results });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const config = require('./config');
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set('trust proxy', true);
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Determine if SSL certs are available
|
// Determine if SSL certs are available
|
||||||
const hasSsl = fs.existsSync(config.sslCert) && fs.existsSync(config.sslKey);
|
const hasSsl = fs.existsSync(config.sslCert) && fs.existsSync(config.sslKey);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue