Add org-level widget sandbox isolation toggle with warnings

Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-08-10 21:14:42 +00:00 committed by GitHub
parent 2cbb8e6349
commit f725186905
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 325 additions and 27 deletions

View file

@ -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`),

View file

@ -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();

View file

@ -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) {
<div id="tokenEditPanel" style="display:none"></div>
</div>
${canManageOrgSecurity ? `
<div class="settings-section">
<h3>Security</h3>
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div style="min-width:260px;flex:1">
<div style="font-weight:600">Widget sandbox isolation</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">
Keep widget code in a null-origin sandbox. Turning this off allows widget code to run with same-origin access.
</div>
</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;white-space:nowrap">
<input type="checkbox" id="widgetSandboxIsolationToggle" ${widgetIsolationDisabled ? '' : 'checked'}>
<span>${widgetIsolationDisabled ? 'Isolation disabled' : 'Isolation enabled'}</span>
</label>
</div>
</div>
` : ''}
${isAdmin ? `
<div class="settings-section">
<h3>${t('settings.license')}</h3>
@ -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 = `
<div class="modal" style="width:min(760px,96vw)">
<div class="modal-header"><h3>Disable widget sandbox isolation for this organization</h3></div>
<div class="modal-body" style="white-space:pre-wrap;line-height:1.45">
Widget HTML currently runs in a null-origin sandbox. That means widget code
cannot read your session, your cookies, or anything else stored by
ScreenTinker in this browser.
Turning this off re-enables allow-same-origin. Widget HTML will then run with
the same privileges as ScreenTinker itself. Any script in any widget in this
organization will be able to:
- Read the session token of every logged-in user who views a display or
preview
- Call the ScreenTinker API as that user, including admin actions
- Read and modify content on every other display in this organization
- Silently exfiltrate all of the above to any server it likes
Because allow-scripts is also required for widgets to function, a widget can
remove its own sandbox entirely once same-origin is granted. There is no
partial protection left after this point.
Only enable this if every widget source in this organization is code you
wrote, or code from a party you would trust with your admin password. A single
compromised third-party embed, CDN, or ad tag is enough.
This setting applies to ALL widgets in this organization and cannot be scoped
per display.
<div class="form-group" style="margin-top:16px">
<label for="widgetSandboxConfirmInput">Type the phrase below to confirm:</label>
<div style="margin:6px 0 8px;font-weight:600">${esc(confirmationPhrase)}</div>
<input id="widgetSandboxConfirmInput" type="text" class="input" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="widgetSandboxConfirmCancel">Cancel</button>
<button class="btn btn-danger" id="widgetSandboxConfirmSubmit" disabled>Disable isolation</button>
</div>
</div>
`;
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() {

View file

@ -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) {
<strong style="color:var(--text-primary)">${t('widget.preview_title')}</strong>
<button class="btn btn-secondary btn-sm" id="pvClose">${t('widget.close')}</button>
</div>
<iframe id="pvIframe" sandbox="allow-scripts" style="flex:1;width:100%;border:0;background:#000"></iframe>
<iframe id="pvIframe" sandbox="${widgetSandboxIsolationDisabled ? 'allow-scripts allow-same-origin' : 'allow-scripts'}" style="flex:1;width:100%;border:0;background:#000"></iframe>
${webpageNote}
</div>`;
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'); }
};

View file

@ -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'))
);

View file

@ -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

View file

@ -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);

View file

@ -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({

View file

@ -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);
</script></body></html>`;
}
function renderText(c) {
function renderText(c, iframeSandbox = 'allow-scripts') {
let html = c.html || '<p style="color:white;padding:20px">Empty text widget</p>';
// 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; }
</style></head><body><iframe sandbox="allow-scripts" srcdoc="${escapeHtml(inner)}"></iframe></body></html>`;
</style></head><body><iframe sandbox="${escapeHtml(iframeSandbox)}" srcdoc="${escapeHtml(inner)}"></iframe></body></html>`;
}
function renderWebpage(c) {
function renderWebpage(c, iframeSandbox = 'allow-scripts') {
const zoom = (c.zoom || 100) / 100;
const invZoom = 100 / (c.zoom || 100) * 100;
return `<!DOCTYPE html><html><head><style>
* { margin:0; } body { height:100vh; overflow:hidden; }
iframe { width:${invZoom}%; height:${invZoom}%; border:0; transform:scale(${zoom}); transform-origin:0 0; }
</style></head><body>
<iframe src="${escapeHtml(safeUrl(c.url))}" sandbox="allow-scripts"></iframe>
<iframe src="${escapeHtml(safeUrl(c.url))}" sandbox="${escapeHtml(iframeSandbox)}"></iframe>
${c.refresh_interval > 0 ? `<script>setInterval(()=>document.querySelector('iframe').src=document.querySelector('iframe').src,${c.refresh_interval * 1000});</script>` : ''}
</body></html>`;
}

View file

@ -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

View file

@ -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', () => {

View file

@ -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/);
});

View file

@ -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'/);
});

View file

@ -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 */ }
}
}