mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 10:43:36 -06:00
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.
62 lines
3.4 KiB
JavaScript
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('<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');
|
|
});
|