mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-19 08:33:56 -06:00
A BrightSign hosting ScreenTinker shows a local page from file:///ssd:/node-server.html that layers the player in an iframe — an iframe rather than a navigation, because navigating replaces the document and kills the poller that notices the server dying. helmet sets X-Frame-Options: SAMEORIGIN, and file:// is not the same origin as http://127.0.0.1:8181, so the frame rendered BLACK. Every asset inside it returned 200 — the player page and all six of its scripts — and nothing appeared in any log. Only the response headers said why, which is a miserable thing to debug on a device with no console. ⚠️ AND IT IS NOT ONLY /player. Chrome evaluates SAMEORIGIN against the TOP-LEVEL document rather than the immediate parent, so with a file:// page at the top, every iframe the player itself uses — widget renders, kiosk views — is blocked by the same rule one level deeper. Scoping this to /player would have cleared the black screen and left every widget in the playlist black instead: the same bug, found later, on a customer's wall. The test covers that case explicitly. Scoped by CONTEXT, not by path: only when the process was started as a player host (bs-server-boot.js sets ST_PLAYER_HOST; nothing else does) AND the request arrived on loopback, i.e. from the box's own browser. An ordinary server keeps SAMEORIGIN, and so does any request off the network — which is where clickjacking would have to come from, since a remote page cannot reach another machine's 127.0.0.1. Where a CSP is set, only its frame-ancestors directive is rewritten; the rest of the policy survives. Verified on XT245 URD3C6000823: the player renders and shows its pairing code, while a request to the same URL from the LAN still returns X-Frame-Options: SAMEORIGIN. Full suite 1766 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
91 lines
4.2 KiB
JavaScript
91 lines
4.2 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* Framing headers when this server IS the display it serves.
|
|
*
|
|
* ⚠️ THE BUG THIS PINS. A BrightSign hosting ScreenTinker shows a local page from
|
|
* `file:///ssd:/node-server.html` that layers the player in an iframe. helmet sets
|
|
* X-Frame-Options: SAMEORIGIN, file:// is not the same origin as http://127.0.0.1:8181, and the
|
|
* result was a BLACK SCREEN on real hardware while every asset inside the frame returned 200 — the
|
|
* player page, its six scripts, all fine, nothing in any log. Only the response headers said why.
|
|
*
|
|
* ⚠️ AND IT IS NOT JUST /player. Chrome evaluates SAMEORIGIN against the TOP-LEVEL document rather
|
|
* than the immediate parent, so with a file:// page at the top, every iframe the player itself uses
|
|
* — widget renders, kiosk views — is blocked by the same rule one level deeper. A fix scoped to
|
|
* /player would have cleared the black screen and left every widget in the playlist black instead.
|
|
* That is why the test below checks a widget-render path too, not only the player.
|
|
*
|
|
* The exception must stay narrow: player-host mode only, loopback only. A normal server must keep
|
|
* SAMEORIGIN, or this "fix" is a clickjacking regression for every hosted install.
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawn } = require('node:child_process');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const { freePort } = require('./helpers/free-port');
|
|
|
|
async function withServer(env, fn) {
|
|
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'st-framing-'));
|
|
const PORT = await freePort();
|
|
const proc = spawn('node', ['server.js'], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', NODE_ENV: 'test', PORT: String(PORT), ...env },
|
|
stdio: 'ignore',
|
|
});
|
|
try {
|
|
for (let i = 0; i < 100; i++) {
|
|
try { const r = await fetch(`http://127.0.0.1:${PORT}/api/status`); if (r.ok) break; } catch { /* not up */ }
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
await fn(`http://127.0.0.1:${PORT}`);
|
|
} finally {
|
|
proc.kill('SIGKILL');
|
|
fs.rmSync(DATA_DIR, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('an ordinary server refuses to be framed', async () => {
|
|
await withServer({}, async (base) => {
|
|
const res = await fetch(`${base}/player/`);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers.get('x-frame-options'), 'SAMEORIGIN',
|
|
'a normal install must keep clickjacking protection');
|
|
});
|
|
});
|
|
|
|
test('a player host lets its own local page frame the player', async () => {
|
|
await withServer({ ST_PLAYER_HOST: '1' }, async (base) => {
|
|
const res = await fetch(`${base}/player/`);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers.get('x-frame-options'), null,
|
|
'the file:// diagnostics page cannot frame the player while this header is set');
|
|
});
|
|
});
|
|
|
|
test('THE DEEPER CASE: nested frames inside the player are freed too', async () => {
|
|
// SAMEORIGIN is judged against the top-level document, which here is file://. If this header
|
|
// survives anywhere the player frames, that content is black even though /player itself renders.
|
|
await withServer({ ST_PLAYER_HOST: '1' }, async (base) => {
|
|
for (const p of ['/', '/app', '/player/debug-overlay.js']) {
|
|
const res = await fetch(`${base}${p}`);
|
|
assert.equal(res.headers.get('x-frame-options'), null,
|
|
`${p} still refuses framing, so anything the player embeds from it renders black`);
|
|
}
|
|
});
|
|
});
|
|
|
|
test('the frame-ancestors directive is relaxed rather than the whole policy dropped', async () => {
|
|
// Where a CSP is set, frame-ancestors 'self' blocks the frame just as effectively as the header
|
|
// above — but the rest of that policy is still worth keeping.
|
|
await withServer({ ST_PLAYER_HOST: '1' }, async (base) => {
|
|
const res = await fetch(`${base}/app`);
|
|
const csp = res.headers.get('content-security-policy');
|
|
if (!csp) return; // no policy on this route, nothing to assert
|
|
assert.match(csp, /frame-ancestors \*/, 'frame-ancestors must be relaxed for the local page');
|
|
assert.match(csp, /default-src/, 'the remainder of the policy must survive');
|
|
});
|
|
});
|