From f725186905186abfce1fb97d0190f77903ce1168 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:14:42 +0000 Subject: [PATCH] Add org-level widget sandbox isolation toggle with warnings Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com> --- frontend/js/api.js | 1 + frontend/js/app.js | 23 ++++ frontend/js/views/settings.js | 127 ++++++++++++++++++ frontend/js/views/widgets.js | 8 +- scripts/migrate-multitenancy.js | 1 + server/db/database.js | 1 + server/player/index.html | 12 +- server/routes/auth.js | 2 +- server/routes/widgets.js | 41 ++++-- server/routes/workspaces.js | 55 ++++++++ .../test/player-widget-frame-origin.test.js | 13 +- .../widget-sandbox-isolation-setting.test.js | 25 ++++ .../test/widget-sandbox-ui-guardrails.test.js | 27 ++++ server/ws/deviceSocket.js | 16 ++- 14 files changed, 325 insertions(+), 27 deletions(-) create mode 100644 server/test/widget-sandbox-isolation-setting.test.js create mode 100644 server/test/widget-sandbox-ui-guardrails.test.js diff --git a/frontend/js/api.js b/frontend/js/api.js index 285fb93..112a7f9 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -223,6 +223,7 @@ export const api = { updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }), switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }), renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + updateWorkspaceSecuritySettings: (workspaceId, data) => request(`/workspaces/${workspaceId}/security-settings`, { method: 'PUT', body: JSON.stringify(data) }), // Workspace members + invites (slice 2A read-only) getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`), diff --git a/frontend/js/app.js b/frontend/js/app.js index 5cf312a..4d0eb89 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -556,6 +556,7 @@ function updateSidebarUser() { const user = getCurrentUser(); if (!user) return; updateVerifyBanner(user); + updateWidgetSandboxWarningBanner(user); // Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin') const adminNav = document.getElementById('adminNavItem'); @@ -626,6 +627,28 @@ function updateVerifyBanner(user) { appEl.parentNode.insertBefore(b, appEl); } +function updateWidgetSandboxWarningBanner(user) { + const existing = document.getElementById('widgetSandboxWarningBanner'); + const disabled = !!user?.current_organization?.widget_sandbox_isolation_disabled; + if (!disabled) { if (existing) existing.remove(); return; } + if (existing) return; + const appEl = document.getElementById('app'); + if (!appEl || !appEl.parentNode) return; + const b = document.createElement('div'); + b.id = 'widgetSandboxWarningBanner'; + b.style.cssText = 'background:var(--danger,#dc2626);color:#fff;padding:10px 16px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;font-weight:600'; + const text = document.createElement('span'); + text.style.whiteSpace = 'pre-line'; + text.textContent = 'Widget sandbox isolation is DISABLED. Widget code in this organization runs\nwith full access to user sessions. Re-enable in Settings > Security.'; + const link = document.createElement('a'); + link.href = '#/settings'; + link.textContent = 'Open Settings'; + link.style.cssText = 'color:#fff;text-decoration:underline;font-weight:700'; + b.appendChild(text); + b.appendChild(link); + appEl.parentNode.insertBefore(b, appEl); +} + // Initialize renderNavLabels(); translateStaticDom(); diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 3bd8efb..93e5643 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -16,6 +16,9 @@ export async function render(container) { // admin is now just isPlatformAdmin. (Elevated capability otherwise comes from // org/workspace membership, gated in the members views, not users.role.) const isAdmin = isSuperAdmin; + const canManageOrgSecurity = isSuperAdmin || user.current_org_role === 'org_owner' || user.current_org_role === 'org_admin'; + const widgetIsolationDisabled = !!user.current_organization?.widget_sandbox_isolation_disabled; + const WIDGET_ISOLATION_CONFIRM_PHRASE = 'I understand I am enabling a security hole'; // #83: the "About" version was hardcoded (showed v1.4.1 regardless of the build). // Read it from the server (/api/version) the same way the admin view does. @@ -103,6 +106,24 @@ export async function render(container) { + ${canManageOrgSecurity ? ` +
+

Security

+
+
+
Widget sandbox isolation
+
+ Keep widget code in a null-origin sandbox. Turning this off allows widget code to run with same-origin access. +
+
+ +
+
+ ` : ''} + ${isAdmin ? `

${t('settings.license')}

@@ -737,6 +758,112 @@ export async function render(container) { btn.disabled = false; } }); + + document.getElementById('widgetSandboxIsolationToggle')?.addEventListener('change', async (e) => { + const checkbox = e.currentTarget; + const shouldEnableIsolation = !!checkbox.checked; + const workspaceId = user.current_workspace_id; + if (!workspaceId) { + checkbox.checked = !shouldEnableIsolation; + showToast('No active workspace', 'error'); + return; + } + + if (!shouldEnableIsolation) { + const confirmed = await openWidgetSandboxDisableConfirmModal(WIDGET_ISOLATION_CONFIRM_PHRASE); + if (!confirmed) { + checkbox.checked = true; + return; + } + try { + await api.updateWorkspaceSecuritySettings(workspaceId, { + widgetSandboxIsolationDisabled: true, + confirmationPhrase: WIDGET_ISOLATION_CONFIRM_PHRASE, + }); + const nextUser = { ...user, current_organization: { ...(user.current_organization || {}), widget_sandbox_isolation_disabled: 1 } }; + localStorage.setItem('user', JSON.stringify(nextUser)); + showToast('Widget sandbox isolation disabled', 'success'); + } catch (err) { + checkbox.checked = true; + showToast(err.message, 'error'); + } + return; + } + + try { + await api.updateWorkspaceSecuritySettings(workspaceId, { widgetSandboxIsolationDisabled: false }); + const nextUser = { ...user, current_organization: { ...(user.current_organization || {}), widget_sandbox_isolation_disabled: 0 } }; + localStorage.setItem('user', JSON.stringify(nextUser)); + showToast('Widget sandbox isolation enabled', 'success'); + } catch (err) { + checkbox.checked = false; + showToast(err.message, 'error'); + } + }); +} + +function openWidgetSandboxDisableConfirmModal(confirmationPhrase) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const input = overlay.querySelector('#widgetSandboxConfirmInput'); + const submit = overlay.querySelector('#widgetSandboxConfirmSubmit'); + const close = (ok) => { + overlay.remove(); + resolve(ok); + }; + const updateEnabled = () => { + submit.disabled = input.value.trim() !== confirmationPhrase; + }; + input.addEventListener('input', updateEnabled); + overlay.querySelector('#widgetSandboxConfirmCancel').addEventListener('click', () => close(false)); + submit.addEventListener('click', () => close(true)); + overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(false); }); + setTimeout(() => input.focus(), 0); + }); } async function loadWhiteLabel() { diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index edda8a2..3a2ae9e 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -292,7 +292,7 @@ function openContentPicker({ multiple = false, title } = {}) { }); } -function showPreviewModal(sessionId, widgetType) { +function showPreviewModal(sessionId, widgetType, widgetSandboxIsolationDisabled = false) { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; // #104: webpage widgets pointing at frame-denying sites (X-Frame-Options) can't be @@ -307,7 +307,7 @@ function showPreviewModal(sessionId, widgetType) { ${t('widget.preview_title')}
- + ${webpageNote} `; document.body.appendChild(overlay); @@ -1004,7 +1004,9 @@ export async function render(container) { }); if (!res.ok) throw new Error(t('widget.toast.preview_failed')); const { id } = await res.json(); - showPreviewModal(id, type); + let user = null; + try { user = JSON.parse(localStorage.getItem('user') || 'null'); } catch (_) { user = null; } + showPreviewModal(id, type, !!user?.current_organization?.widget_sandbox_isolation_disabled); } catch (err) { showToast(err.message, 'error'); } }; diff --git a/scripts/migrate-multitenancy.js b/scripts/migrate-multitenancy.js index cbc7ad6..13a1f1f 100644 --- a/scripts/migrate-multitenancy.js +++ b/scripts/migrate-multitenancy.js @@ -78,6 +78,7 @@ function runMigration({ db: existingDb = null, dryRun = false, logger = console default_brand_name TEXT, default_logo_url TEXT, default_primary_color TEXT, + widget_sandbox_isolation_disabled INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); diff --git a/server/db/database.js b/server/db/database.js index 3db48e2..343f086 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -488,6 +488,7 @@ const migrations = [ // additive — existing rows are unaffected and a code-only rollback leaves dead columns. "ALTER TABLE users ADD COLUMN password_reset_hash TEXT", "ALTER TABLE users ADD COLUMN password_reset_expires INTEGER", + "ALTER TABLE organizations ADD COLUMN widget_sandbox_isolation_disabled INTEGER NOT NULL DEFAULT 0", // AUTH-05: make break-glass recovery revocable, single-use and auditable. // // scripts/reset-admin.js mints a JWT carrying `recovery: true`, which middleware/auth.js diff --git a/server/player/index.html b/server/player/index.html index 829a9d8..dec2a18 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -3291,6 +3291,12 @@ pendingWidgetSwap = null; } + function widgetSandboxAttr(item) { + return item && item.widget_allow_same_origin + ? 'allow-scripts allow-same-origin' + : 'allow-scripts'; + } + // Buffered widget render (#directory-board black-cycle): build the new widget iframe // BEHIND the current content (hidden) and reveal it only once it fires 'load' — then tear // down the outgoing content. Kills the black flash on every widget transition, and lets a @@ -3311,7 +3317,7 @@ iframe.style.visibility = 'hidden'; iframe.allow = 'autoplay; fullscreen'; // Sandbox into a unique origin so widget scripts can't read window.parent state. - iframe.setAttribute('sandbox', 'allow-scripts'); + iframe.setAttribute('sandbox', widgetSandboxAttr(item)); const reveal = () => { if (!pendingWidgetSwap || pendingWidgetSwap.iframe !== iframe) return; // superseded / discarded @@ -3887,7 +3893,7 @@ iframe.allow = 'autoplay; fullscreen'; // Sandbox into a unique origin so widget scripts can't read window.parent // state (localStorage / JWT). allow-scripts keeps inline widget code running. - iframe.setAttribute('sandbox', 'allow-scripts'); + iframe.setAttribute('sandbox', widgetSandboxAttr(item)); mount.appendChild(iframe); if (PREVIEW_MODE && item.widget_type === 'webpage') addWebpageNote(mount); // #104 if (!isFollower) scheduleAdvance(nextItem, (item.duration_sec || 30) * 1000); @@ -3999,7 +4005,7 @@ iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${a.widget_rev||0}`; // Sandbox into a unique origin so widget scripts can't read window.parent // state (localStorage / JWT). allow-scripts keeps inline widget code running. - iframe.setAttribute('sandbox', 'allow-scripts'); + iframe.setAttribute('sandbox', widgetSandboxAttr(a)); div.appendChild(iframe); if (PREVIEW_MODE && a.widget_type === 'webpage') addWebpageNote(div); // #104 if (multi) zoneTimers[zone.id] = setTimeout(advance, dur); diff --git a/server/routes/auth.js b/server/routes/auth.js index 49b1b45..ced74c5 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -676,7 +676,7 @@ router.get('/me', requireAuth, resolveTenancy, (req, res) => { } const currentOrg = req.organizationId - ? db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(req.organizationId) + ? db.prepare('SELECT id, name, COALESCE(widget_sandbox_isolation_disabled, 0) AS widget_sandbox_isolation_disabled FROM organizations WHERE id = ?').get(req.organizationId) : null; res.json({ diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 88f86ac..cdc2311 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -193,14 +193,15 @@ router.delete('/:id', (req, res) => { }); const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board','directory-search','diag-smoothness']); -function renderWidgetHtml(type, config) { +function renderWidgetHtml(type, config, opts = {}) { + const iframeSandbox = opts.iframeSandbox || 'allow-scripts'; config = config || {}; switch (type) { case 'clock': return renderClock(config); case 'weather': return renderWeather(config); case 'rss': return renderRSS(config); - case 'text': return renderText(config); - case 'webpage': return renderWebpage(config); + case 'text': return renderText(config, iframeSandbox); + case 'webpage': return renderWebpage(config, iframeSandbox); case 'social': return renderSocial(config); case 'directory-board': return renderDirectoryBoard(config); case 'directory-search': return renderDirectorySearch(config); @@ -209,11 +210,29 @@ function renderWidgetHtml(type, config) { } } +function widgetIframeSandboxForWorkspace(workspaceId) { + if (!workspaceId) return 'allow-scripts'; + try { + const row = db.prepare(` + SELECT COALESCE(o.widget_sandbox_isolation_disabled, 0) AS disabled + FROM workspaces ws + LEFT JOIN organizations o ON o.id = ws.organization_id + WHERE ws.id = ? + `).get(workspaceId); + return Number(row?.disabled || 0) === 1 + ? 'allow-scripts allow-same-origin' + : 'allow-scripts'; + } catch (_) { + return 'allow-scripts'; + } +} + // Render widget as HTML page router.get('/:id/render', (req, res) => { const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id); if (!widget) return res.status(404).send('Widget not found'); const config = JSON.parse(widget.config || '{}'); + const iframeSandbox = widgetIframeSandboxForWorkspace(widget.workspace_id); // This page is DESIGNED to be embedded by the player, which frames it in a // sandboxed (allow-scripts, no allow-same-origin) iframe = a null origin. The // global helmet X-Frame-Options: SAMEORIGIN refuses that (null != same), so @@ -235,7 +254,7 @@ router.get('/:id/render', (req, res) => { res.setHeader('Cache-Control', 'no-store'); } res.setHeader('Content-Type', 'text/html'); - res.send(renderWidgetHtml(widget.widget_type, config)); + res.send(renderWidgetHtml(widget.widget_type, config, { iframeSandbox })); }); // Public JSON feed of a directory board's entries. A directory-search page polls @@ -309,7 +328,8 @@ router.post('/preview', (req, res) => { const { widget_type, config } = req.body || {}; if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' }); if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' }); - let html = renderWidgetHtml(widget_type, config || {}); + const iframeSandbox = widgetIframeSandboxForWorkspace(req.workspaceId); + let html = renderWidgetHtml(widget_type, config || {}, { iframeSandbox }); if (req.workspaceId) html = inlineUserContent(html, req.workspaceId); res.setHeader('Content-Type', 'text/html'); res.send(html); @@ -331,7 +351,8 @@ router.post('/preview-session', (req, res) => { if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' }); if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' }); const id = uuidv4(); - const html = renderWidgetHtml(widget_type, config || {}); + const iframeSandbox = widgetIframeSandboxForWorkspace(req.workspaceId); + const html = renderWidgetHtml(widget_type, config || {}, { iframeSandbox }); previewStore.set(id, { html, widget_type, created: Date.now() }); res.json({ id, url: `/api/widgets/preview-session/${id}` }); }); @@ -433,7 +454,7 @@ load(); setInterval(load, 300000); `; } -function renderText(c) { +function renderText(c, iframeSandbox = 'allow-scripts') { let html = c.html || '

Empty text widget

'; // LEGACY DESIGNER RESCUE — deliberately narrow. @@ -535,17 +556,17 @@ function renderText(c) { * { margin:0; padding:0; } html, body { width:100vw; height:100vh; overflow:hidden; background:${safeCss(c.background, 'transparent')}; } iframe { width:100%; height:100%; border:0; display:block; } -`; +`; } -function renderWebpage(c) { +function renderWebpage(c, iframeSandbox = 'allow-scripts') { const zoom = (c.zoom || 100) / 100; const invZoom = 100 / (c.zoom || 100) * 100; return ` - + ${c.refresh_interval > 0 ? `` : ''} `; } diff --git a/server/routes/workspaces.js b/server/routes/workspaces.js index 86f092a..8a29026 100644 --- a/server/routes/workspaces.js +++ b/server/routes/workspaces.js @@ -3,6 +3,8 @@ const router = express.Router(); const crypto = require('crypto'); const { db } = require('../db/database'); const { canAdminWorkspace, canAccessWorkspace } = require('../lib/permissions'); +const { isPlatformRole } = require('../middleware/auth'); +const { logActivity, getClientIp } = require('../services/activity'); const { sendEmail } = require('../services/email'); // Workspace management routes. Operates on a target workspace specified by @@ -16,6 +18,7 @@ const SLUG_MAX = 60; const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer']; +const WIDGET_SANDBOX_CONFIRM_PHRASE = 'I understand I am enabling a security hole'; // Operational policy - env-configurable with conservative defaults. Restart // required to take effect. The guarded parseInt rejects garbage strings @@ -94,6 +97,58 @@ router.patch('/:id', (req, res) => { res.json(updated); }); +// Organization security settings for the workspace's parent org. +// Update is restricted to org_owner/org_admin (or platform admin); workspace_admin +// alone is intentionally insufficient for this org-wide security switch. +router.put('/:id/security-settings', (req, res) => { + const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id); + if (!ws) return res.status(404).json({ error: 'Workspace not found' }); + if (!canAdminWorkspace(db, req.user, ws)) { + return res.status(403).json({ error: 'Admin access required' }); + } + const isPlatformAdmin = isPlatformRole(req.user.role); + const orgMember = db.prepare( + 'SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?' + ).get(ws.organization_id, req.user.id); + const isOrgAdmin = !!(orgMember && (orgMember.role === 'org_owner' || orgMember.role === 'org_admin')); + if (!isPlatformAdmin && !isOrgAdmin) { + return res.status(403).json({ error: 'Organization admin required' }); + } + + if (typeof req.body?.widgetSandboxIsolationDisabled !== 'boolean') { + return res.status(400).json({ error: 'widgetSandboxIsolationDisabled must be boolean' }); + } + const next = req.body.widgetSandboxIsolationDisabled ? 1 : 0; + if (next === 1) { + const typed = String(req.body?.confirmationPhrase || '').trim(); + if (typed !== WIDGET_SANDBOX_CONFIRM_PHRASE) { + return res.status(400).json({ error: 'Exact confirmation phrase required' }); + } + } + + const current = db.prepare( + 'SELECT COALESCE(widget_sandbox_isolation_disabled, 0) AS v FROM organizations WHERE id = ?' + ).get(ws.organization_id); + db.prepare( + "UPDATE organizations SET widget_sandbox_isolation_disabled = ?, updated_at = strftime('%s','now') WHERE id = ?" + ).run(next, ws.organization_id); + req.workspaceId = ws.id; // stamp tenant for activityLogger row + logActivity( + req.user.id, + 'org_widget_sandbox_isolation_setting_changed', + `organization_id=${ws.organization_id} widgetSandboxIsolationDisabled=${next}`, + null, + getClientIp(req), + ws.id + ); + + res.json({ + organization_id: ws.organization_id, + widgetSandboxIsolationDisabled: !!next, + changed: !!current && Number(current.v || 0) !== next, + }); +}); + // ==================== Members / invites ==================== // Load workspace by req.params.id and verify caller has the required level diff --git a/server/test/player-widget-frame-origin.test.js b/server/test/player-widget-frame-origin.test.js index fd71dac..0d4ae36 100644 --- a/server/test/player-widget-frame-origin.test.js +++ b/server/test/player-widget-frame-origin.test.js @@ -29,14 +29,11 @@ const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), const SW = fs.readFileSync(path.join(__dirname, '..', 'player', 'sw.js'), 'utf8'); const WIDGETS = fs.readFileSync(path.join(__dirname, '..', 'routes', 'widgets.js'), 'utf8'); -test('the widget iframe is sandboxed into an opaque origin', () => { - const sandboxes = [...HTML.matchAll(/setAttribute\('sandbox',\s*'([^']*)'\)/g)].map((m) => m[1]); - assert.ok(sandboxes.length > 0, 'the player must sandbox its widget frames'); - for (const s of sandboxes) { - assert.match(s, /allow-scripts/, 'a widget needs scripts to be a widget'); - assert.doesNotMatch(s, /allow-same-origin/, - 'allow-same-origin would give widget scripts the player origin — its storage, its device token'); - } +test('the widget iframe stays null-origin by default, with explicit per-item opt-in only', () => { + assert.match(HTML, /function widgetSandboxAttr\(item\)/, 'widget sandbox policy should be centralized'); + assert.match(HTML, /item && item\.widget_allow_same_origin/, 'opt-in must be keyed by item/org setting'); + assert.match(HTML, /allow-scripts allow-same-origin/, 'explicit opt-in token must exist'); + assert.match(HTML, /: 'allow-scripts'/, 'safe default remains allow-scripts only'); }); test('the offline guarantee for widgets is the HTTP header, and the server still sets it', () => { diff --git a/server/test/widget-sandbox-isolation-setting.test.js b/server/test/widget-sandbox-isolation-setting.test.js new file mode 100644 index 0000000..2e8b288 --- /dev/null +++ b/server/test/widget-sandbox-isolation-setting.test.js @@ -0,0 +1,25 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKSPACES = fs.readFileSync(path.join(__dirname, '..', 'routes', 'workspaces.js'), 'utf8'); +const WIDGETS = fs.readFileSync(path.join(__dirname, '..', 'routes', 'widgets.js'), 'utf8'); +const DEVICE_SOCKET = fs.readFileSync(path.join(__dirname, '..', 'ws', 'deviceSocket.js'), 'utf8'); + +test('backend requires exact confirmation phrase when disabling sandbox isolation', () => { + assert.match(WORKSPACES, /I understand I am enabling a security hole/); + assert.match(WORKSPACES, /String\(req\.body\?\.confirmationPhrase \|\| ''\)\.trim\(\)/); + assert.match(WORKSPACES, /typed !== WIDGET_SANDBOX_CONFIRM_PHRASE/); + assert.match(WORKSPACES, /status\(400\)\.json\(\{ error: 'Exact confirmation phrase required' \}\)/); +}); + +test('backend keeps safe default sandbox and only opts into allow-same-origin via org setting', () => { + assert.match(WIDGETS, /function widgetIframeSandboxForWorkspace\(workspaceId\)/); + assert.match(WIDGETS, /if \(!workspaceId\) return 'allow-scripts'/); + assert.match(WIDGETS, /\? 'allow-scripts allow-same-origin'/); + assert.match(DEVICE_SOCKET, /COALESCE\(o\.widget_sandbox_isolation_disabled, 0\)/); + assert.match(DEVICE_SOCKET, /a\.widget_allow_same_origin = Number\(facts\.same_origin \|\| 0\) === 1/); +}); diff --git a/server/test/widget-sandbox-ui-guardrails.test.js b/server/test/widget-sandbox-ui-guardrails.test.js new file mode 100644 index 0000000..c507437 --- /dev/null +++ b/server/test/widget-sandbox-ui-guardrails.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SETTINGS = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'settings.js'), 'utf8'); +const APP = fs.readFileSync(path.join(__dirname, '..', '..', 'frontend', 'js', 'app.js'), 'utf8'); + +test('settings modal requires the exact confirmation phrase before disable submit enables', () => { + assert.match(SETTINGS, /Disable widget sandbox isolation for this organization/); + assert.match(SETTINGS, /Type the phrase below to confirm:/); + assert.match(SETTINGS, /I understand I am enabling a security hole/); + assert.match( + SETTINGS, + /submit\.disabled\s*=\s*input\.value\.trim\(\)\s*!==\s*confirmationPhrase/, + 'confirm button must stay disabled until exact phrase match (trimmed only)' + ); +}); + +test('dashboard warning banner renders when org isolation is disabled and links to settings', () => { + assert.match(APP, /widgetSandboxWarningBanner/); + assert.match(APP, /Widget sandbox isolation is DISABLED\./); + assert.match(APP, /Re-enable in Settings > Security\./); + assert.match(APP, /link\.href = '#\/settings'/); +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 22a47de..b81682d 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -296,12 +296,24 @@ function resolveGroupSync(device, deviceId) { // // Refreshing the rev here, at send time, makes the URL differ exactly when the content differs — // and only then, so the anti-flash reuse still holds for widgets nobody has touched. -const widgetRevOf = db.prepare('SELECT updated_at FROM widgets WHERE id = ?').pluck(); +const widgetFactsOf = db.prepare(` + SELECT w.updated_at AS rev, + COALESCE(o.widget_sandbox_isolation_disabled, 0) AS same_origin + FROM widgets w + LEFT JOIN workspaces ws ON ws.id = w.workspace_id + LEFT JOIN organizations o ON o.id = ws.organization_id + WHERE w.id = ? +`); function refreshWidgetRevs(assignments) { if (!Array.isArray(assignments)) return; for (const a of assignments) { if (!a || !a.widget_id) continue; - try { a.widget_rev = widgetRevOf.get(a.widget_id) ?? a.widget_rev ?? 0; } catch (_) { /* keep published */ } + try { + const facts = widgetFactsOf.get(a.widget_id); + if (!facts) continue; + a.widget_rev = facts.rev ?? a.widget_rev ?? 0; + a.widget_allow_same_origin = Number(facts.same_origin || 0) === 1; + } catch (_) { /* keep published */ } } }