mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
Five low-risk, high-value fixes surfaced by the security review: #3 Branding lockdown — `custom_domain`/`custom_css` (which feed the PUBLIC, pre-auth branding resolver and the login-page <style>) are now settable only by platform admins; a workspace_admin can no longer hijack the platform login page by claiming its domain. The public /api/branding (+ /domain) now return only presentational fields via publicBranding() (no id/user_id/workspace_id/ custom_domain/timestamps leak). #6 Strip device_token — the device WS auth secret (validated with timingSafeEqual) was returned in device list/get/update + pairing responses (SELECT d.* / *). New lib/device-sanitize.js strips it everywhere; prevents device impersonation by any workspace user. #7 must_change_password enforced server-side — was a frontend-only redirect, so a provisioned temp password worked indefinitely via the API. requireAuth now 403s every route except GET/PUT /api/auth/me (the password change, which clears the flag) and logout while the flag is set. #8 XSS — escape user data interpolated into innerHTML in teams.js, kiosk.js, layout-editor.js (team/page/layout/zone names, member name/email, kiosk config fields). scriptSrcAttr 'unsafe-inline' made these exploitable via injected event handlers, not just markup. #9 Thumbnail IDOR — /api/content/:id/thumbnail had no auth/scope gate (any UUID served any tenant's thumbnail). Now mirrors the /file route's playlist/widget workspace-scoped reference check. Tests: new test/security-fixes.test.js (device strip, publicBranding field allowlist, must_change_password gate). Full suite 41/41. Verified live against a prod-data copy: device_token absent from /api/devices, /api/branding trimmed. Not addressed here (tracked for follow-up): Android OTA signature verification (Critical), public widget-render XSS, token revocation/logout, pairing-code strength, validateRemoteUrl hardening, import quota. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
6.3 KiB
JavaScript
148 lines
6.3 KiB
JavaScript
const jwt = require('jsonwebtoken');
|
|
const config = require('../config');
|
|
const { db } = require('../db/database');
|
|
|
|
// Phase 2.1: JWT now optionally carries the user's current workspace_id so
|
|
// the tenancy middleware can resolve scope without an extra DB lookup on
|
|
// every request. Callers that don't know the workspace yet (legacy paths,
|
|
// recovery tokens) pass null and the tenancy resolver falls back to the
|
|
// user's first accessible workspace.
|
|
function generateToken(user, currentWorkspaceId) {
|
|
return jwt.sign(
|
|
{ id: user.id, email: user.email, role: user.role, current_workspace_id: currentWorkspaceId || null },
|
|
config.jwtSecret,
|
|
{ algorithm: 'HS256', expiresIn: config.jwtExpiry }
|
|
);
|
|
}
|
|
|
|
function verifyToken(token) {
|
|
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
|
|
}
|
|
|
|
// Synthetic user record for recovery tokens (scripts/reset-admin.js). Not
|
|
// persisted; only exists for the lifetime of the request.
|
|
function recoveryUser(decoded) {
|
|
return {
|
|
id: decoded.id,
|
|
email: decoded.email || 'admin@localhost',
|
|
name: 'Recovery Admin',
|
|
role: decoded.role || 'platform_admin',
|
|
auth_provider: 'recovery',
|
|
avatar_url: null,
|
|
plan_id: 'enterprise'
|
|
};
|
|
}
|
|
|
|
// Express middleware - requires valid JWT
|
|
function requireAuth(req, res, next) {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
|
|
try {
|
|
const token = authHeader.split(' ')[1];
|
|
const decoded = verifyToken(token);
|
|
if (decoded.recovery) {
|
|
req.user = recoveryUser(decoded);
|
|
req.jwtWorkspaceId = null;
|
|
return next();
|
|
}
|
|
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id);
|
|
if (!user) return res.status(401).json({ error: 'User not found' });
|
|
req.user = user;
|
|
// Tenancy middleware reads this on the resolver step.
|
|
req.jwtWorkspaceId = decoded.current_workspace_id || null;
|
|
// #7: enforce the forced first-login password change SERVER-SIDE (was a
|
|
// frontend-only redirect, so a provisioned temp password worked indefinitely
|
|
// via the API). While the flag is set, allow only reading/updating one's own
|
|
// profile (the password change is PUT /api/auth/me, which clears the flag)
|
|
// and logout; block everything else.
|
|
if (user.must_change_password) {
|
|
const url = (req.originalUrl || '').split('?')[0].replace(/\/$/, '');
|
|
const allowed = url === '/api/auth/me' || url === '/api/auth/logout';
|
|
if (!allowed) return res.status(403).json({ error: 'password_change_required' });
|
|
}
|
|
next();
|
|
} catch (err) {
|
|
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
}
|
|
}
|
|
|
|
// Optional auth - sets req.user if token present, continues either way
|
|
function optionalAuth(req, res, next) {
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
try {
|
|
const token = authHeader.split(' ')[1];
|
|
const decoded = verifyToken(token);
|
|
req.user = decoded.recovery
|
|
? recoveryUser(decoded)
|
|
: db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
|
|
req.jwtWorkspaceId = decoded.current_workspace_id || null;
|
|
} catch (err) {
|
|
// Token invalid, continue without user
|
|
}
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Phase 2.1: role rename. Phase 1 renamed 'superadmin' to 'platform_admin' and
|
|
// dropped the in-between 'admin' role. These two guards are widened to accept
|
|
// either spelling so existing callers keep working without per-route edits.
|
|
// New code should prefer requirePlatformAdmin / requireOrgAdmin / workspace
|
|
// role guards from server/lib/permissions.js.
|
|
//
|
|
// Issue #14 (role normalization): the data migration in db/database.js collapses
|
|
// any legacy 'superadmin' -> 'platform_admin' and 'admin' -> 'user'. 'superadmin'
|
|
// is kept in PLATFORM_ROLES purely as back-compat belt-and-suspenders (recovery
|
|
// tokens, stray strings) - no row should carry it post-migration. Owner-level
|
|
// power lives here in PLATFORM_ROLES; anything not in this set is denied.
|
|
|
|
const PLATFORM_ROLES = ['superadmin', 'platform_admin'];
|
|
const ELEVATED_ROLES = ['admin', 'superadmin', 'platform_admin'];
|
|
|
|
// isPlatformRole: single predicate for "is this string a platform-owner role".
|
|
// Use this instead of a bare `role === 'platform_admin'` so a stray 'superadmin'
|
|
// is never silently treated as lower-privileged (the act-as bug fixed in #14).
|
|
// NOTE: this is the OWNER tier only - it deliberately does NOT include
|
|
// 'platform_operator' (issue #13), which is cross-org staff, not an owner.
|
|
function isPlatformRole(role) {
|
|
return PLATFORM_ROLES.includes(role);
|
|
}
|
|
|
|
// Issue #13: platform_operator is cross-org STAFF - it can see and act-as into
|
|
// every org and read/write workspace-scoped resources there, but holds NO
|
|
// owner-level power (no billing, no org/workspace deletion, no user/role
|
|
// management, no shared/template asset curation, no branding). The owner powers
|
|
// stay gated on PLATFORM_ROLES / isPlatformRole, which operator is deliberately
|
|
// NOT a member of - so every owner capability is deny-by-default for operators,
|
|
// and any NEW owner endpoint added later inherits that denial automatically.
|
|
//
|
|
// PLATFORM_STAFF / isPlatformStaff is the union used ONLY for cross-org
|
|
// VISIBILITY + act-as + workspace-scoped read/write. It must never gate an
|
|
// owner action.
|
|
const PLATFORM_STAFF = ['superadmin', 'platform_admin', 'platform_operator'];
|
|
function isPlatformStaff(role) {
|
|
return PLATFORM_STAFF.includes(role);
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (!req.user || !ELEVATED_ROLES.includes(req.user.role)) {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requireSuperAdmin(req, res, next) {
|
|
if (!req.user || !PLATFORM_ROLES.includes(req.user.role)) {
|
|
return res.status(403).json({ error: 'Platform admin access required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Preferred alias for new code.
|
|
const requirePlatformAdmin = requireSuperAdmin;
|
|
|
|
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES, PLATFORM_STAFF, ELEVATED_ROLES };
|