mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 00:54:56 +00:00
102 lines
4.1 KiB
JavaScript
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');
|
|
});
|