mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
fix(preview): server-side preview sessions to bypass CSP (#151)
* 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:
parent
147ab6d3c8
commit
90b8cbb1e6
|
|
@ -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'); }
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue