fix(preview): server-side preview sessions to bypass CSP (#151)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

* 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 <fabianma7@gmail.com>
This commit is contained in:
Fabian Mendoza 2026-07-09 16:39:07 -04:00 committed by GitHub
parent 147ab6d3c8
commit 90b8cbb1e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 44 additions and 10 deletions

View file

@ -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}
</div>`;
document.body.appendChild(overlay);
// srcdoc resolves relative URLs against about:srcdoc, so inject <base> pointing to our origin
const baseTag = `<base href="${window.location.origin}/">`;
const withBase = /<head[^>]*>/i.test(html)
? html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`)
: html.replace(/<html([^>]*)>/i, `<html$1><head>${baseTag}</head>`);
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'); }
};

View file

@ -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 `<!DOCTYPE html><html><head><style>
* { margin:0; padding:0; box-sizing:border-box; }

View file

@ -129,6 +129,7 @@ app.use((req, res, next) => {
if (req.path.startsWith('/player')) return next();
if (req.path === '/docs') return next(); // Redoc API reference needs a relaxed CSP
if (req.path.startsWith('/api/widgets/') && req.path.endsWith('/render')) return next();
if (req.path.startsWith('/api/widgets/preview-session/')) return next();
if (req.path.startsWith('/api/kiosk/') && req.path.endsWith('/render')) return next();
return dashboardCsp(req, res, next);
});
@ -536,7 +537,9 @@ const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS } = require('./config/a
// Public device-render endpoints + the memory-heavy preview limiter must be registered
// BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first.
app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
app.get('/api/widgets/preview-session/:id', (req, res, next) => { req._skipAuth = true; next(); });
app.use('/api/widgets/preview', rateLimit(60000, 30)); // base64 inline = memory-intensive
app.use('/api/widgets/preview-session', rateLimit(60000, 30)); // preview session creation retains rendered HTML in memory for 5min
app.get('/api/kiosk/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
for (const r of PUBLIC_ROUTERS) {