mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
c105a5941e
commit
8ec33721f7
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
// 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);
|
||||
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: (origin, callback) => {
|
||||
// Allow requests with no origin (mobile apps, server-to-server, kiosk iframes)
|
||||
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);
|
||||
},
|
||||
origin: corsOriginCheck,
|
||||
credentials: true,
|
||||
}));
|
||||
// Stripe webhook needs raw body (before express.json parses it)
|
||||
|
|
|
|||
Loading…
Reference in a new issue