screentinker/server/lib/branding.js
ScreenTinker ba3e2cc785 fix(security): patch quick-win findings from the codebase review
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>
2026-06-08 19:02:19 -05:00

64 lines
2.7 KiB
JavaScript

'use strict';
// Issue #15: instance-level default white-label branding.
//
// Branding is stored per-workspace in white_labels (keyed by workspace_id). This
// adds a single "platform default" row that every workspace inherits unless it
// has set its own. Resolution order:
// 1. the current workspace's row (per-workspace override; unchanged)
// 2. a custom-domain match (public/pre-login white-label hosts)
// 3. the platform-default row (instance default, #15)
// 4. hardcoded ScreenTinker fallback
//
// The platform-default row is identified by a FIXED id (not "workspace_id IS
// NULL"): legacy pre-multitenancy white_labels rows can also have a null
// workspace_id, so a null-scope sentinel would be ambiguous. A fixed id is not.
//
// Override is ROW-LEVEL: a workspace that has any row uses it wholesale; only
// workspaces with NO row fall through to the platform default. No row-copying at
// creation, so editing the platform default propagates everywhere instantly.
const PLATFORM_DEFAULT_ID = 'platform-default';
const HARDCODED_BRANDING = {
brand_name: 'ScreenTinker',
logo_url: null,
favicon_url: null,
primary_color: '#3B82F6',
secondary_color: '#1E293B',
bg_color: '#111827',
custom_css: null,
hide_branding: 0,
};
// The single platform-default row (fixed id), or null if none has been set.
function platformDefaultRow(db) {
return db.prepare('SELECT * FROM white_labels WHERE id = ?').get(PLATFORM_DEFAULT_ID) || null;
}
// Resolve effective branding for a context. Pass whichever you have:
// { workspaceId } for the authed app, { domain } for the public/login path.
function resolveBranding(db, { workspaceId = null, domain = null } = {}) {
if (workspaceId) {
const wl = db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(workspaceId);
if (wl) return wl;
}
if (domain) {
const wl = db.prepare('SELECT * FROM white_labels WHERE custom_domain = ?').get(domain);
if (wl) return wl;
}
return platformDefaultRow(db) || { ...HARDCODED_BRANDING };
}
// Presentational fields only. The PUBLIC resolver (GET /api/branding) and the
// by-domain lookup must not leak internal columns (id, user_id, workspace_id,
// custom_domain, timestamps) to unauthenticated / cross-tenant callers.
const PUBLIC_BRANDING_FIELDS = ['brand_name', 'logo_url', 'favicon_url', 'primary_color', 'secondary_color', 'bg_color', 'custom_css', 'hide_branding'];
function publicBranding(row) {
const out = {};
for (const f of PUBLIC_BRANDING_FIELDS) out[f] = row ? (row[f] ?? null) : null;
return out;
}
module.exports = { resolveBranding, platformDefaultRow, publicBranding, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID };