mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 10:43:36 -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>
71 lines
3.7 KiB
JavaScript
71 lines
3.7 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
// Phase 2.2f: workspace-scoped branding. POST gated by requireWorkspaceAdmin
|
|
// per the design doc (branding is a workspace_admin power, not editor).
|
|
const { requireWorkspaceAdmin } = require('../lib/permissions');
|
|
const { resolveBranding, publicBranding } = require('../lib/branding');
|
|
|
|
// Get the current workspace's effective branding. #15: when the workspace has no
|
|
// row of its own, fall through to the platform default (workspace_id IS NULL)
|
|
// instead of the hardcoded ScreenTinker default, so unbranded/new workspaces
|
|
// inherit the instance brand.
|
|
router.get('/', (req, res) => {
|
|
res.json(resolveBranding(db, { workspaceId: req.workspaceId || null }));
|
|
});
|
|
|
|
// Get branding by custom domain. #15: domain match -> platform default ->
|
|
// hardcoded. (Mounted behind requireAuth like the rest of this router; the
|
|
// public/pre-login path is GET /api/branding, registered before auth.)
|
|
router.get('/domain/:domain', (req, res) => {
|
|
res.json(publicBranding(resolveBranding(db, { domain: req.params.domain })));
|
|
});
|
|
|
|
// Create or update the current workspace's white-label config. Restricted to
|
|
// workspace_admin / org_owner / org_admin / platform_admin.
|
|
router.post('/', requireWorkspaceAdmin, (req, res) => {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before configuring branding.' });
|
|
|
|
const { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color,
|
|
custom_domain, custom_css, hide_branding } = req.body;
|
|
|
|
// Security (#3): custom_domain drives the PUBLIC, pre-auth branding resolver
|
|
// (GET /api/branding) and custom_css is injected into the login page's <style>.
|
|
// A workspace_admin who set custom_domain to the platform's own host would
|
|
// hijack every visitor's login page (defacement / fake-login CSS). Both are
|
|
// powerful, cross-tenant-affecting fields - restrict them to platform admins.
|
|
const setsDomain = custom_domain !== undefined && custom_domain !== null && custom_domain !== '';
|
|
const setsCss = custom_css !== undefined && custom_css !== null && custom_css !== '';
|
|
if (!req.isPlatformAdmin && (setsDomain || setsCss)) {
|
|
return res.status(403).json({ error: 'custom_domain and custom_css can only be set by a platform administrator.' });
|
|
}
|
|
|
|
let wl = db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(req.workspaceId);
|
|
|
|
if (wl) {
|
|
const fields = { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding };
|
|
const updates = [];
|
|
const values = [];
|
|
Object.entries(fields).forEach(([k, v]) => {
|
|
if (v !== undefined) { updates.push(`${k} = ?`); values.push(v); }
|
|
});
|
|
if (updates.length) {
|
|
updates.push("updated_at = strftime('%s','now')");
|
|
values.push(req.workspaceId);
|
|
db.prepare(`UPDATE white_labels SET ${updates.join(', ')} WHERE workspace_id = ?`).run(...values);
|
|
}
|
|
} else {
|
|
const id = uuidv4();
|
|
db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
id, req.user.id, req.workspaceId, brand_name || 'ScreenTinker', logo_url || null, favicon_url || null,
|
|
primary_color || '#3B82F6', secondary_color || '#1E293B', bg_color || '#111827',
|
|
custom_domain || null, custom_css || null, hide_branding ? 1 : 0);
|
|
}
|
|
|
|
res.json(db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(req.workspaceId));
|
|
});
|
|
|
|
module.exports = router;
|