From 90b8cbb1e6b5800fd84950228727f7031d7872cb Mon Sep 17 00:00:00 2001 From: Fabian Mendoza Date: Thu, 9 Jul 2026 16:39:07 -0400 Subject: [PATCH] fix(preview): server-side preview sessions to bypass CSP (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(preview): replace srcdoc with server-side preview sessions to bypass CSP Widget previews (clock, weather, etc.) were rendered via iframe.srcdoc, which inherits the dashboard CSP script-src 'self'. This blocked the inline scripts widgets need (setInterval for clock, fetch for weather), causing previews to show blank/static content. Replace srcdoc with ephemeral server-side preview sessions: - POST /api/widgets/preview-session — stores rendered HTML (Map, 5min TTL) - GET /api/widgets/preview-session/:id — serves the HTML via iframe src, bypassing CSP like the device render endpoint already does The old /api/widgets/preview endpoint is unchanged for backward compat. * fix(preview): add rate limiter for /preview-session route --------- Co-authored-by: BlazzzPlay --- frontend/js/views/widgets.js | 15 +++++---------- server/routes/widgets.js | 36 ++++++++++++++++++++++++++++++++++++ server/server.js | 3 +++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index 60dfa49..06cf613 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -105,7 +105,7 @@ function openContentPicker({ multiple = false, title } = {}) { }); } -function showPreviewModal(html, widgetType) { +function showPreviewModal(sessionId, widgetType) { 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 @@ -124,12 +124,7 @@ function showPreviewModal(html, widgetType) { ${webpageNote} `; document.body.appendChild(overlay); - // srcdoc resolves relative URLs against about:srcdoc, so inject pointing to our origin - const baseTag = ``; - const withBase = /]*>/i.test(html) - ? html.replace(/]*)>/i, `${baseTag}`) - : html.replace(/]*)>/i, `${baseTag}`); - overlay.querySelector('#pvIframe').srcdoc = withBase; + overlay.querySelector('#pvIframe').src = '/api/widgets/preview-session/' + sessionId; const close = () => overlay.remove(); overlay.querySelector('#pvClose').onclick = close; overlay.onclick = (e) => { if (e.target === overlay) close(); }; @@ -551,14 +546,14 @@ export async function render(container) { if (!type) return; const config = getConfigFromForm(type); try { - const res = await fetch('/api/widgets/preview', { + const res = await fetch('/api/widgets/preview-session', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ widget_type: type, config }), }); if (!res.ok) throw new Error(t('widget.toast.preview_failed')); - const html = await res.text(); - showPreviewModal(html, type); + const { id } = await res.json(); + showPreviewModal(id, type); } catch (err) { showToast(err.message, 'error'); } }; diff --git a/server/routes/widgets.js b/server/routes/widgets.js index a854b03..7212b15 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -212,6 +212,42 @@ router.post('/preview', (req, res) => { res.send(html); }); +// Preview sessions — ephemeral store so the preview iframe loads via src (not srcdoc) +// and bypasses the dashboard CSP that would block the widget's inline scripts. +const previewStore = new Map(); +const PREVIEW_TTL = 5 * 60 * 1000; +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of previewStore) { + if (now - entry.created > PREVIEW_TTL) previewStore.delete(key); + } +}, 60 * 1000).unref(); + +router.post('/preview-session', (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' }); + const id = uuidv4(); + const html = renderWidgetHtml(widget_type, config || {}); + previewStore.set(id, { html, widget_type, created: Date.now() }); + res.json({ id, url: `/api/widgets/preview-session/${id}` }); +}); + +router.get('/preview-session/:id', (req, res) => { + const entry = previewStore.get(req.params.id); + if (!entry) return res.status(410).send('Preview expired'); + if (Date.now() - entry.created > PREVIEW_TTL) { + previewStore.delete(req.params.id); + return res.status(410).send('Preview expired'); + } + let html = entry.html; + if (req.workspaceId) html = inlineUserContent(html, req.workspaceId); + res.removeHeader('X-Frame-Options'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Type', 'text/html'); + res.send(html); +}); + function renderClock(c) { return `