'use strict';
// A kiosk's style values are interpolated into a cannot be reached), but every panel displaying that kiosk page then fetches an
// attacker-chosen URL — an outbound beacon, and a tracking channel across sites.
//
// The containment is STRUCTURAL, not an allowlist of values: `background` is a free-text field in
// the editor, so linear-gradient(), rgb() and url() are all legitimate and must keep working.
// Only characters that could terminate the declaration or open a new rule are refused.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const { freePort } = require('./helpers/free-port');
let PORT, BASE, proc;
const DATA_DIR = path.join(os.tmpdir(), 'st-kioskcss-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-kioskcss-' + crypto.randomBytes(4).toString('hex') + '.log');
const S = {};
const jfetch = async (p, opts = {}) => {
const res = await fetch(BASE + p, opts);
const raw = await res.text();
let body = null; try { body = JSON.parse(raw); } catch { /* html */ }
return { status: res.status, body, text: raw };
};
const auth = () => ({ Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' });
before(async () => {
PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
const reg = await jfetch('/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'k' + crypto.randomBytes(5).toString('hex') + '@x.local', password: 'Passw0rd123' }),
});
S.token = reg.body.token;
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
async function renderWithStyle(style) {
const r = await jfetch('/api/kiosk', {
method: 'POST', headers: auth(),
body: JSON.stringify({ name: 'k' + crypto.randomBytes(4).toString('hex'), config: { title: 'T', style, buttons: [] } }),
});
assert.ok(r.body && r.body.id, `kiosk created (got ${r.status})`);
const page = await jfetch(`/api/kiosk/${r.body.id}/render`);
assert.equal(page.status, 200);
const block = (page.text.match(/