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>
64 lines
2.7 KiB
JavaScript
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 };
|