mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Merge pull request #254 from ChrisChrome/main
Add org-level widget sandbox toggle.
This commit is contained in:
commit
6aeb703efe
|
|
@ -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)); }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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