mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
fix(security): sanitize public widget render to close stored XSS
The public, CSP-exempt widget render (GET /api/widgets/:id/render) inlined config values straight into <style>/CSS and (for the text widget) raw into the same-origin document. A workspace editor could store `}</style><script>...` in a color/background/size field (bypassing the UI pickers via the API) → stored XSS executing in the app origin for anyone who opens the render URL (JWT theft). - safeCss(): allow colors/gradients but reject CSS breakout / url() / @import / expression / javascript:. Applied to background/color across clock, weather, rss, social renders. - safeNumber(): coerce font_size / scroll_speed / max_items to a finite number so they can't smuggle markup. - Text widget keeps its intentional raw HTML/CSS feature, but it now renders inside an <iframe sandbox="allow-scripts"> (NO allow-same-origin) - scripts run in a null origin that can't reach the dashboard's localStorage/JWT. Tests: test/widget-render-xss.test.js (breakout rejected, numbers coerced, text isolated, legit colors/gradients preserved). Full suite green.
This commit is contained in:
parent
50ad1f670b
commit
401c4b00b5
|
|
@ -65,6 +65,21 @@ function safeUrl(url) {
|
|||
} catch { return 'about:blank'; }
|
||||
}
|
||||
|
||||
// Security: widget render output is public and CSP-exempt, so config values that
|
||||
// get inlined into <style>/CSS must not be able to break out (a config field set
|
||||
// via the API could otherwise carry `}</style><script>...`). safeCss allows
|
||||
// colors/gradients but rejects breakout/exfil constructs; safeNumber coerces to
|
||||
// a finite number (so e.g. font_size can't smuggle markup).
|
||||
function safeCss(v, fallback) {
|
||||
if (typeof v !== 'string') return fallback;
|
||||
if (/[<>{}\\;]/.test(v) || /url\s*\(/i.test(v) || /@import/i.test(v) || /expression/i.test(v) || /javascript:/i.test(v)) return fallback;
|
||||
return v.trim().slice(0, 200);
|
||||
}
|
||||
function safeNumber(v, fallback) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
// List widgets accessible to the caller's current workspace, plus any
|
||||
// platform-template rows (workspace_id IS NULL) shared with all workspaces.
|
||||
// Phase 2.2d: workspace-scoped. Cross-workspace visibility comes from
|
||||
|
|
@ -186,9 +201,9 @@ router.post('/preview', (req, res) => {
|
|||
function renderClock(c) {
|
||||
return `<!DOCTYPE html><html><head><style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:${c.background || 'transparent'}; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:-apple-system,sans-serif; overflow:hidden; }
|
||||
#time { font-size:${c.font_size || 64}px; font-weight:700; color:${c.color || '#FFFFFF'}; }
|
||||
#date { font-size:${Math.max(16, (c.font_size || 64) / 3)}px; color:${c.color || '#FFFFFF'}; opacity:0.7; margin-top:8px; }
|
||||
body { background:${safeCss(c.background, 'transparent')}; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:-apple-system,sans-serif; overflow:hidden; }
|
||||
#time { font-size:${safeNumber(c.font_size, 64)}px; font-weight:700; color:${safeCss(c.color, '#FFFFFF')}; }
|
||||
#date { font-size:${Math.max(16, safeNumber(c.font_size, 64) / 3)}px; color:${safeCss(c.color, '#FFFFFF')}; opacity:0.7; margin-top:8px; }
|
||||
</style></head><body>
|
||||
<div id="time"></div>
|
||||
${c.show_date !== false ? '<div id="date"></div>' : ''}
|
||||
|
|
@ -205,9 +220,9 @@ setInterval(update, 1000); update();
|
|||
function renderWeather(c) {
|
||||
return `<!DOCTYPE html><html><head><style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:${c.background || 'transparent'}; display:flex; align-items:center; justify-content:center; height:100vh; font-family:-apple-system,sans-serif; color:${c.color || '#FFF'}; }
|
||||
body { background:${safeCss(c.background, 'transparent')}; display:flex; align-items:center; justify-content:center; height:100vh; font-family:-apple-system,sans-serif; color:${safeCss(c.color, '#FFF')}; }
|
||||
.weather { text-align:center; }
|
||||
.temp { font-size:${c.font_size || 48}px; font-weight:700; }
|
||||
.temp { font-size:${safeNumber(c.font_size, 48)}px; font-weight:700; }
|
||||
.location { font-size:18px; opacity:0.7; margin-top:4px; }
|
||||
.desc { font-size:16px; opacity:0.6; margin-top:8px; }
|
||||
.icon { font-size:64px; }
|
||||
|
|
@ -240,9 +255,9 @@ load(); setInterval(load, 600000);
|
|||
function renderRSS(c) {
|
||||
return `<!DOCTYPE html><html><head><style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:${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 ${c.scroll_speed || 30}s linear infinite; }
|
||||
.item { display:inline-block; padding:0 40px; font-size:${c.font_size || 24}px; color:${c.color || '#FFF'}; }
|
||||
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; }
|
||||
.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%)} }
|
||||
|
|
@ -253,7 +268,7 @@ 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, ${c.max_items || 10}) || [];
|
||||
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 => {
|
||||
const el = document.createElement('span'); el.textContent = i.title;
|
||||
|
|
@ -272,12 +287,21 @@ function renderText(c) {
|
|||
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
|
||||
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
|
||||
});
|
||||
return `<!DOCTYPE html><html><head><style>
|
||||
// Security: c.html / c.css are intentionally raw user-authored content, but the
|
||||
// render is public and same-origin with the dashboard - injected <script> could
|
||||
// otherwise read the dashboard's localStorage JWT. Render the user content inside
|
||||
// a sandboxed iframe with NO allow-same-origin: scripts still run (so legit
|
||||
// widget markup works) but in a null origin that can't touch the app's storage.
|
||||
const inner = `<!DOCTYPE html><html><head><style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:${c.background || 'transparent'}; width:100vw; height:100vh; overflow:hidden; }
|
||||
html, body { width:100vw; height:100vh; overflow:hidden; }
|
||||
${c.css || ''}
|
||||
</style></head><body>${html}</body></html>`;
|
||||
// NOTE: c.html is intentionally rendered as raw HTML - this is user-authored content for the text widget
|
||||
return `<!DOCTYPE html><html><head><style>
|
||||
* { 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>`;
|
||||
}
|
||||
|
||||
function renderWebpage(c) {
|
||||
|
|
@ -294,7 +318,7 @@ ${c.refresh_interval > 0 ? `<script>setInterval(()=>document.querySelector('ifra
|
|||
|
||||
function renderSocial(c) {
|
||||
return `<!DOCTYPE html><html><head><style>
|
||||
body { background:${c.background || '#000'}; color:${c.color || '#FFF'}; font-family:-apple-system,sans-serif; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; }
|
||||
body { background:${safeCss(c.background, '#000')}; color:${safeCss(c.color, '#FFF')}; font-family:-apple-system,sans-serif; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; }
|
||||
</style></head><body>
|
||||
<div style="text-align:center">
|
||||
<p style="font-size:24px">Social Feed</p>
|
||||
|
|
|
|||
61
server/test/widget-render-xss.test.js
Normal file
61
server/test/widget-render-xss.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use strict';
|
||||
|
||||
// Verifies the public widget render endpoint sanitizes config that gets inlined
|
||||
// into <style>/CSS (clock/weather/rss/social) and isolates the text widget's
|
||||
// raw HTML in a sandboxed, null-origin iframe.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret-widget-xss';
|
||||
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE widgets (id TEXT PRIMARY KEY, widget_type TEXT, config TEXT, workspace_id TEXT);`);
|
||||
const dbModulePath = require.resolve('../db/database');
|
||||
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } };
|
||||
|
||||
const express = require('express');
|
||||
const widgetsRouter = require('../routes/widgets');
|
||||
const app = express();
|
||||
app.use('/api/widgets', widgetsRouter);
|
||||
const server = app.listen(0);
|
||||
let base;
|
||||
test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; });
|
||||
test.after(() => { server.close(); db.close(); });
|
||||
|
||||
const seed = (id, type, config) => db.prepare('INSERT INTO widgets (id, widget_type, config, workspace_id) VALUES (?,?,?,?)').run(id, type, JSON.stringify(config), 'ws1');
|
||||
const render = async (id) => (await fetch(`${base}/api/widgets/${id}/render`)).text();
|
||||
|
||||
const CSS_BREAKOUT = 'red}</style><script>document.title="pwned"</script><style>{';
|
||||
|
||||
test('clock widget: malicious background/color/font_size cannot break out of <style>', async () => {
|
||||
seed('clock1', 'clock', { background: CSS_BREAKOUT, color: CSS_BREAKOUT, font_size: '64px}</style><script>x</script>' });
|
||||
const html = await render('clock1');
|
||||
assert.ok(!html.includes('</style><script>document.title'), 'CSS breakout payload must be rejected');
|
||||
assert.ok(html.includes('background:transparent'), 'invalid background falls back to default');
|
||||
assert.ok(/font-size:64px/.test(html), 'invalid font_size falls back to numeric default');
|
||||
});
|
||||
|
||||
test('rss widget: scroll_speed/max_items coerced to numbers (no injection)', async () => {
|
||||
seed('rss1', 'rss', { scroll_speed: '30s}</style><script>y</script>', max_items: '10);evil(' , background: CSS_BREAKOUT });
|
||||
const html = await render('rss1');
|
||||
assert.ok(!html.includes('</style><script>y'), 'scroll_speed cannot inject');
|
||||
assert.ok(!html.includes('evil('), 'max_items cannot inject into the script');
|
||||
assert.ok(html.includes('background:#000'), 'invalid background -> default');
|
||||
});
|
||||
|
||||
test('text widget: raw HTML is isolated in a null-origin sandboxed iframe', async () => {
|
||||
seed('text1', 'text', { html: '<script>parent.localStorage.token</script>', css: 'body{}' });
|
||||
const html = await render('text1');
|
||||
assert.ok(html.includes('<iframe sandbox="allow-scripts"'), 'user HTML wrapped in sandboxed iframe');
|
||||
assert.ok(!/<body[^>]*>\s*<script>parent\.localStorage/.test(html), 'raw script must not sit in the top-level (same-origin) document');
|
||||
assert.ok(html.includes('<script>parent.localStorage'), 'user script is escaped into srcdoc, runs only in the sandboxed frame');
|
||||
});
|
||||
|
||||
test('valid color/gradient backgrounds are preserved', async () => {
|
||||
seed('clock2', 'clock', { background: 'linear-gradient(45deg, #ff0000, #00ff00)', color: '#3B82F6' });
|
||||
const html = await render('clock2');
|
||||
assert.ok(html.includes('linear-gradient(45deg, #ff0000, #00ff00)'), 'legit gradient preserved');
|
||||
assert.ok(html.includes('color:#3B82F6'), 'legit hex color preserved');
|
||||
});
|
||||
Loading…
Reference in a new issue