screentinker/server/test/widget-render-xss.test.js
ScreenTinker 401c4b00b5 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.
2026-06-08 19:11:14 -05:00

62 lines
3.4 KiB
JavaScript

'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('&lt;script&gt;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');
});