Merge pull request #254 from ChrisChrome/main

Add org-level widget sandbox toggle.
This commit is contained in:
screentinker 2026-08-11 15:45:39 -05:00 committed by GitHub
commit 6aeb703efe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 388 additions and 40 deletions

View file

@ -291,10 +291,24 @@ body {
50% { opacity: 0.4; }
}
.content {
/* Wraps the (optional) banners strip and main content so they stack
vertically as a single flex column, independent from the fixed sidebar.
Without this wrapper, #banners and .content would be direct siblings in
the (row-direction) body flexbox, turning the banner into a narrow flex
item next to the content instead of a full-width strip above it, and
shifting the whole dashboard layout out of alignment with the sidebar. */
.main-wrapper {
margin-left: var(--sidebar-width);
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
min-width: 0;
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 24px 32px;
}
@ -1504,7 +1518,8 @@ body {
}
.sidebar-backdrop.open { display: block; }
.nav-link { min-height: 44px; padding: 10px 14px; }
.content { margin-left: 0; padding: 16px; padding-top: 68px; }
.main-wrapper { margin-left: 0; }
.content { padding: 16px; padding-top: 68px; }
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.device-grid { grid-template-columns: 1fr; }
.content-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }

View file

@ -174,9 +174,13 @@
</div>
</nav>
<main class="content" id="app">
<!-- Views rendered here -->
</main>
<div class="main-wrapper">
<div id="banners"></div>
<main class="content" id="app">
<!-- Views rendered here -->
</main>
</div>
<!-- Add Device Modal -->
<div class="modal-overlay" id="addDeviceModal" style="display:none">

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

@ -574,6 +574,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,8 +627,8 @@ function updateVerifyBanner(user) {
const unverified = user && user.email_verified === 0 && user.auth_provider === 'local';
if (!unverified) { if (existing) existing.remove(); return; }
if (existing) return;
const appEl = document.getElementById('app');
if (!appEl || !appEl.parentNode) return;
const bannersEl = document.getElementById('banners');
if (!bannersEl) return;
const b = document.createElement('div');
b.id = 'verifyBanner';
b.style.cssText = 'background:var(--warning,#f59e0b);color:#1a1200;padding:9px 16px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap';
@ -641,7 +642,29 @@ function updateVerifyBanner(user) {
catch { showToast(t('auth.verify_resend_failed'), 'error'); }
});
b.appendChild(btn);
appEl.parentNode.insertBefore(b, appEl);
bannersEl.appendChild(b);
}
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 bannersEl = document.getElementById('banners');
if (!bannersEl) 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);
bannersEl.appendChild(b);
}
// Initialize

View file

@ -38,7 +38,6 @@ export function openTypeToConfirmModal(opts = {}) {
const input = overlay.querySelector('#ttcInput');
const confirmBtn = overlay.querySelector('#ttcConfirm');
const errorEl = overlay.querySelector('#ttcError');
input.focus();
const matches = () => input.value.trim() === String(expected);
input.addEventListener('input', () => { confirmBtn.disabled = !matches(); });

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.
@ -132,6 +135,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>
@ -1148,6 +1169,111 @@ 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); });
});
}
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

@ -587,6 +587,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

@ -766,7 +766,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}` });
});
@ -406,34 +427,67 @@ load(); setInterval(load, 600000);
}
function renderRSS(c) {
// scroll_speed is authored in the UI as "seconds" (legacy field), but that used to be wired
// straight into animation-duration: a *fixed total time* for the whole strip to cross the
// screen. That makes the on-screen speed depend on how much content there is - a feed with
// many items gets dragged through in the same {scroll_speed}s as a feed with one, so it
// flies past far too fast, never lets the reader finish, and simply "jumps back to the
// start" once the fixed duration is up. Instead we treat scroll_speed as calibrating a
// constant px/sec rate (using one viewport-width per scroll_speed seconds as the reference,
// matching prior behaviour for content that fits in one screen), then measure the actual
// rendered width of the ticker and derive a duration long enough to move that full distance
// at the same constant speed - so more items simply take proportionally longer, and every
// item scrolls fully into and out of view before the loop restarts.
const scrollSpeedSec = safeNumber(c.scroll_speed, 30);
return `<!DOCTYPE html><html><head><style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:${safeCss(c.background, '#000')}; height:100vh; overflow:hidden; font-family:-apple-system,sans-serif; }
.ticker { display:flex; align-items:center; height:100%; white-space:nowrap; animation:scroll ${safeNumber(c.scroll_speed, 30)}s linear infinite; }
.ticker { display:flex; align-items:center; height:100%; white-space:nowrap; position:relative; will-change:transform; }
.item { display:inline-block; padding:0 40px; font-size:${safeNumber(c.font_size, 24)}px; color:${safeCss(c.color, '#FFF')}; }
.item .title { font-weight:600; }
.item .sep { margin:0 20px; opacity:0.3; }
@keyframes scroll { 0%{transform:translateX(100vw)} 100%{transform:translateX(-100%)} }
</style></head><body>
<div class="ticker" id="ticker"><div class="item">Loading feed...</div></div>
<script>
var SCROLL_SPEED_SEC = ${scrollSpeedSec};
var ticker = document.getElementById('ticker');
var anim = null;
function restartAnimation() {
if (anim) { anim.cancel(); anim = null; }
var viewportW = window.innerWidth;
var tickerW = ticker.scrollWidth;
// Reference speed: one viewport-width travelled every SCROLL_SPEED_SEC seconds, so the
// default of 30s behaves the same as before for a feed that fits within one screen.
var pxPerSec = viewportW / SCROLL_SPEED_SEC;
var distance = viewportW + tickerW; // starts fully off-screen right, ends fully off-screen left
var durationMs = Math.max(1000, (distance / pxPerSec) * 1000);
anim = ticker.animate(
[
{ transform: 'translateX(' + viewportW + 'px)' },
{ transform: 'translateX(-' + tickerW + 'px)' },
],
{ duration: durationMs, iterations: Infinity, easing: 'linear' }
);
}
async function load() {
try {
const r = await fetch('https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent('${escapeHtml(c.feed_url) || ''}'));
const d = await r.json();
const items = d.items?.slice(0, ${safeNumber(c.max_items, 10)}) || [];
// NOTE: RSS feed titles are external content - using textContent instead of innerHTML to prevent XSS
document.getElementById('ticker').innerHTML = items.map(i => {
ticker.innerHTML = items.map(i => {
const el = document.createElement('span'); el.textContent = i.title;
return '<div class="item"><span class="title">' + el.innerHTML + '</span></div><div class="item sep">•</div>';
}).join('') || '<div class="item">No items</div>';
} catch(e) { document.getElementById('ticker').innerHTML = '<div class="item">Feed unavailable</div>'; }
} catch(e) { ticker.innerHTML = '<div class="item">Feed unavailable</div>'; }
requestAnimationFrame(restartAnimation);
}
window.addEventListener('resize', restartAnimation);
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 +589,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
@ -18,6 +20,7 @@ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
// chosen email became stored XSS in the platform admin's user list.
const EMAIL_RE = /^[^\s@<>"'`\\;,()\[\]]+@[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/;
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
@ -96,6 +99,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 */ }
}
}