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.
+
+
+
+
+ ${widgetIsolationDisabled ? 'Isolation disabled' : 'Isolation enabled'}
+
+
+
+ ` : ''}
+
${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 = `
+
+
+
+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.
+
+
+
+
+ `;
+ 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')}
${t('widget.close')}
-
+
${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);