Files
Haven/test/httpModeHeaders.test.js
T

102 lines
4.1 KiB
JavaScript

/**
* Security headers in plain-HTTP mode.
*
* A Haven serving plain HTTP used to send upgrade-insecure-requests and HSTS
* anyway, so a remote visitor's browser re-requested every stylesheet and
* script over https on a port with nothing listening for TLS: unstyled page,
* dead buttons. Whoever set it up saw a perfect page, because browsers treat
* localhost as trustworthy and skip the upgrade. Back then a missing
* certificate was enough to land there (the Windows installer skipped the
* certificate whenever OpenSSL was missing, which reached a self-hoster on
* 2026-09-05); now Haven makes its own certificate, and plain HTTP is only
* ever the explicit FORCE_HTTP=true mode this test runs in.
*
* node --test test/httpModeHeaders.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { spawn } = require('node:child_process');
const PORT = 3397;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA = path.join(os.tmpdir(), `haven-httpmode-${Date.now()}`);
let server;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const head = (p) => new Promise((res, rej) => {
http.get(`${BASE}${p}`, (r) => { r.resume(); res(r.headers); }).on('error', rej);
});
test.before(async () => {
fs.mkdirSync(DATA, { recursive: true });
// Plain HTTP on purpose. A certless start without FORCE_HTTP now makes its
// own certificate (see autoCert.integration.test.js), so FORCE_HTTP=true is
// the one remaining way to serve plain HTTP, and the headers that assume
// HTTPS must stay off in it.
const env = { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', HAVEN_DATA_DIR: DATA, ADMIN_USERNAME: 'admin', FORCE_HTTP: 'true' };
delete env.SSL_CERT_PATH;
delete env.SSL_KEY_PATH;
server = spawn(process.execPath, ['server.js'], { cwd: path.join(__dirname, '..'), env, stdio: 'ignore' });
for (let i = 0; i < 60; i++) {
try {
await new Promise((res, rej) =>
http.get(`${BASE}/api/health`, (r) => (r.statusCode === 200 ? res() : rej())).on('error', rej));
return;
} catch { await wait(500); }
}
throw new Error('server did not start');
});
test.after(() => {
server?.kill();
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch { /* ignore */ }
});
test('plain HTTP does not tell browsers to upgrade every request', async () => {
const h = await head('/');
const csp = h['content-security-policy'] || '';
assert.ok(csp, 'a CSP is still sent');
assert.ok(!/upgrade-insecure-requests/.test(csp), `CSP must not upgrade on plain HTTP: ${csp}`);
});
test('plain HTTP does not claim HSTS', async () => {
const h = await head('/');
assert.equal(h['strict-transport-security'], undefined);
});
test('the rest of the CSP is untouched', async () => {
const csp = (await head('/'))['content-security-policy'] || '';
for (const directive of ["default-src 'self'", "object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'"]) {
assert.ok(csp.includes(directive), `${directive} still present`);
}
});
test('the login page and its assets load over plain HTTP', async () => {
// The visible half of the bug: these are the requests a browser upgraded.
for (const p of ['/', '/css/style.css', '/js/auth.js']) {
const status = await new Promise((res, rej) => {
http.get(`${BASE}${p}`, (r) => { r.resume(); res(r.statusCode); }).on('error', rej);
});
assert.equal(status, 200, `${p} served`);
}
});
test('the Flash player comes from Haven itself, not a CDN', async () => {
// Ruffle used to load from unpkg, and the games pages' CSP allowed any
// script from there, which is any package anyone publishes to npm.
for (const p of ['/', '/games/flash.html']) {
const csp = (await head(p))['content-security-policy'] || '';
assert.ok(!/unpkg/.test(csp), `${p} CSP names no CDN: ${csp}`);
}
const h = await head('/games/ruffle/ruffle.js');
assert.match(h['content-type'] || '', /javascript/, 'the pinned player is served');
});