Security: sanitize notes, add CSP headers, tighten CORS

LOW 1 (notes XSS): device.notes textarea content now goes through
esc(). Notes weren't in the sanitizeBody allow-list at write time, so
HTML in the field would render unescaped on the device-detail page.

LOW 2 (CSP): enabled Helmet contentSecurityPolicy with default-src
'self', script-src 'self', style-src 'self' 'unsafe-inline', plus the
data:/blob:/https: image and media sources the player needs. Strict
script-src blocks <script> injection; script-src-attr 'unsafe-inline'
keeps existing inline onclick handlers working until they can be
refactored to addEventListener (TODO comment in code).

  CSP applies to /app and most other paths. Skipped on the public
  widget and kiosk render endpoints, the landing page, and /player —
  those legitimately need inline scripts/styles. upgrade-insecure-
  requests is explicitly disabled so HTTP-only self-hosted LAN
  deployments aren't broken.

  Refactored two inline onclick handlers in index.html to data-close-
  modal attributes wired by a delegated listener in app.js. Was the
  only blocker for /app under strict script-src.

LOW 3 (CORS): Express CORS now only allows screentinker.com (and
subdomains) + localhost in production. SELF_HOSTED=true bypasses the
allowlist (operator owns their deployment). Development mode stays
open. Same policy applied to the Socket.IO CORS config which was
previously origin: '*'. Native clients (Android, server-to-server,
kiosk iframes) send no Origin and pass through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-28 14:37:31 -05:00
parent c105a5941e
commit 8ec33721f7
3 changed files with 95 additions and 13 deletions

View file

@ -157,7 +157,7 @@
<div class="modal" style="max-width:560px">
<div class="modal-header">
<h3>Add Display</h3>
<button class="btn-icon" onclick="document.getElementById('addDeviceModal').style.display='none'">
<button class="btn-icon" data-close-modal="addDeviceModal">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
@ -193,7 +193,7 @@
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('addDeviceModal').style.display='none'">Cancel</button>
<button class="btn btn-secondary" data-close-modal="addDeviceModal">Cancel</button>
<button class="btn btn-primary" id="pairDeviceBtn">Pair Display</button>
</div>
</div>

View file

@ -304,3 +304,12 @@ if (isAuthenticated()) {
}
window.addEventListener('hashchange', route);
route();
// Close-modal buttons (replaces inline onclick handlers — required for CSP).
document.addEventListener('click', (e) => {
const closer = e.target.closest('[data-close-modal]');
if (!closer) return;
const id = closer.dataset.closeModal;
const modal = document.getElementById(id);
if (modal) modal.style.display = 'none';
});

View file

@ -29,27 +29,100 @@ if (hasSsl) {
server = http.createServer(app);
}
// Socket.IO CORS is checked via the same corsOriginCheck function defined below
// (after config is loaded). Hoisted into a closure so we can reference it before
// the function is defined — at first connection time, corsOriginCheck exists.
const io = new Server(server, {
cors: { origin: '*' },
cors: {
origin: (origin, cb) => corsOriginCheck(origin, cb),
credentials: true,
},
maxHttpBufferSize: 10 * 1024 * 1024 // 10MB for screenshot uploads
});
// Middleware
const helmet = require('helmet');
// CSP applies to the dashboard / app pages only. Widget and kiosk renders are
// publicly accessed by devices and intentionally use inline scripts/styles —
// they're served from /api/widgets/:id/render and /api/kiosk/:id/render and
// skip the CSP layer below via path-based opt-out.
//
// scriptSrc 'self' blocks <script> injection (the primary XSS vector) and external
// JS. scriptSrcAttr 'unsafe-inline' allows existing onclick/onchange handlers on
// dashboard buttons — TODO: refactor these to addEventListener and tighten further.
// styleSrcAttr 'unsafe-inline' is required because the views use inline style="..."
// attributes extensively for layout.
const dashboardCsp = helmet.contentSecurityPolicy({
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
styleSrcAttr: ["'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
mediaSrc: ["'self'", 'blob:', 'https:'],
connectSrc: ["'self'", 'wss:', 'ws:', 'https:'],
fontSrc: ["'self'", 'data:'],
frameSrc: ["'self'", 'https://www.youtube.com', 'https://youtube.com'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
// Don't force HTTPS — self-hosted deployments may run on HTTP-only LANs.
// Public production traffic is upgraded by Cloudflare / the reverse proxy and
// protected by the HSTS header set above.
upgradeInsecureRequests: null,
},
});
app.use(helmet({
contentSecurityPolicy: false, // Allow inline scripts in widget renders
crossOriginEmbedderPolicy: false, // Allow loading external widget content
contentSecurityPolicy: false, // we apply our own below, scoped to non-render paths
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({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, server-to-server, kiosk iframes)
// Apply CSP everywhere except routes that legitimately need inline scripts:
// - widget/kiosk renders (public, fetched by devices, intentionally inline)
// - /player (the web player has inline JS, served to display devices)
// - / (landing page has inline JSON-LD + a pricing fetch script)
// The dashboard at /app uses ES modules only and gets the strict policy.
app.use((req, res, next) => {
if (req.path === '/' || req.path === '/landing.html') return next();
if (req.path.startsWith('/player')) return next();
if (req.path.startsWith('/api/widgets/') && req.path.endsWith('/render')) return next();
if (req.path.startsWith('/api/kiosk/') && req.path.endsWith('/render')) return next();
return dashboardCsp(req, res, next);
});
// CORS policy.
// - SELF_HOSTED=true: allow all origins (operator controls their own deployment).
// - production: allowlist screentinker.com (+ subdomains) and localhost dev.
// - development: open (default).
// Auth is JWT in Authorization header — credentials:true is kept for any cookie-based
// future flows but the JWT stays in localStorage and is sent via fetch() explicitly,
// so an attacker origin can't ride a session.
const isProd = process.env.NODE_ENV === 'production';
const allowedHostsProd = [
'screentinker.com',
'www.screentinker.com',
'localhost',
'127.0.0.1',
];
function corsOriginCheck(origin, callback) {
// No origin = same-origin / mobile app / server-to-server / kiosk iframe.
if (!origin) return callback(null, true);
// Allow all origins - auth is handled by JWT, not CORS
// Devices, kiosks, and web players need cross-origin access
callback(null, true);
},
if (config.selfHosted) return callback(null, true);
if (!isProd) return callback(null, true);
let host;
try { host = new URL(origin).hostname; } catch { return callback(null, false); }
const allowed = allowedHostsProd.some(h => host === h || host.endsWith('.' + h));
if (allowed) return callback(null, true);
callback(null, false);
}
app.use(cors({
origin: corsOriginCheck,
credentials: true,
}));
// Stripe webhook needs raw body (before express.json parses it)