mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Add org-level widget sandbox isolation toggle with warnings
Co-authored-by: ChrisChrome <28414320+ChrisChrome@users.noreply.github.com>
This commit is contained in:
parent
2cbb8e6349
commit
f725186905
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
25
server/test/widget-sandbox-isolation-setting.test.js
Normal file
25
server/test/widget-sandbox-isolation-setting.test.js
Normal 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/);
|
||||
});
|
||||
27
server/test/widget-sandbox-ui-guardrails.test.js
Normal file
27
server/test/widget-sandbox-ui-guardrails.test.js
Normal 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'/);
|
||||
});
|
||||
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue