BentoPDF v2.8.8 built from source at its pinned commit with BentoPDF's own air-gapped build options, every module it would load from a CDN vendored and checked against vendor.lock, a Content-Security-Policy without a single host, digital-sign-pdf and timestamp-pdf disabled because both need a third party, and the hand-off glue on every page: the AGPL source link, the hand-in of a file from bau-pm through a one-minute cookie into sessionStorage, confirmed by the tool's reaction, the hand-back of a result from every window, and the signing tool's flatten switch locked on. The zero-host gate drives all 117 enabled tool pages in Chromium and fails on any request to another origin, any CSP violation, a CSP that names a host, and a disabled id that names no page. Green on this image (3 min 52 s on a private network), red on the stock upstream image (115 findings) and on a misspelled id. CI runs it on every push, the release workflow before it pushes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
6.2 KiB
JavaScript
140 lines
6.2 KiB
JavaScript
// The gate this repository exists for (bau-pm PLAN.md §2.50, decision 2): a
|
|
// page of the PDF tools may talk to its own origin and to nobody else.
|
|
//
|
|
// node test/zero-hosts.mjs <base-url> [chromium-path]
|
|
//
|
|
// 1. The Content-Security-Policy of the start page names no host.
|
|
// 2. Every id config.json disables names a real page, and that page offers
|
|
// no tool.
|
|
// 3. Every tool page linked from the German overview is opened in a phone-sized
|
|
// Chromium, given a probe file its input accepts, and its main button
|
|
// pressed. Any request to another origin fails the gate, and so does any CSP
|
|
// violation, because a blocked request is a tool that tried to call out.
|
|
//
|
|
// Exits 0 when nothing left the origin, 1 otherwise, 2 when it could not run.
|
|
// Fed the stock upstream image once, it must fail (see README, "The gate").
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const base = (process.argv[2] || '').replace(/\/$/, '');
|
|
const executablePath = process.argv[3] || process.env.CHROMIUM || '/usr/bin/chromium';
|
|
if (!base) {
|
|
console.error('usage: zero-hosts.mjs <base-url> [chromium]');
|
|
process.exit(2);
|
|
}
|
|
|
|
const { chromium } = await import(process.env.PLAYWRIGHT_CORE || 'playwright-core');
|
|
const origin = new URL(base).origin;
|
|
const probes = {
|
|
pdf: readFileSync(join(here, 'fixtures/probe.pdf')),
|
|
png: readFileSync(join(here, 'fixtures/probe.png')),
|
|
};
|
|
const failures = [];
|
|
|
|
// --- 1. the header -----------------------------------------------------------
|
|
const head = await fetch(`${base}/de/`);
|
|
const csp = head.headers.get('content-security-policy') || '';
|
|
if (!csp) failures.push('start page: no Content-Security-Policy');
|
|
for (const host of csp.match(/https?:[^\s;]*/g) || []) {
|
|
failures.push(`CSP names a host: ${host}`);
|
|
}
|
|
|
|
// --- 2. the disabled tools exist, and are disabled ----------------------------
|
|
// A misspelled id in config.json disables nothing and says nothing (found on
|
|
// the first run: `digital-sign` for the page digital-sign-pdf.html), so every
|
|
// id must name a real page, and that page must not offer its tool.
|
|
const config = await (await fetch(`${base}/config.json`)).json();
|
|
const disabled = config.disabledTools || [];
|
|
if (disabled.length === 0) failures.push('config.json disables no tool');
|
|
|
|
// --- 3. every tool -----------------------------------------------------------
|
|
// The origin counts as secure even over plain HTTP on a CI network, so the
|
|
// page is cross-origin isolated as it is behind TLS in production, and the
|
|
// Office tools run the code they run there rather than a fallback.
|
|
const browser = await chromium.launch({
|
|
executablePath,
|
|
args: ['--no-sandbox', `--unsafely-treat-insecure-origin-as-secure=${origin}`],
|
|
});
|
|
const overview = await browser.newPage();
|
|
await overview.goto(`${base}/de/`, { waitUntil: 'networkidle' });
|
|
const tools = [...new Set(await overview.$$eval('a[href$=".html"]', (links) =>
|
|
links.map((a) => new URL(a.href, location.href)).filter((u) => u.origin === location.origin)
|
|
.map((u) => u.pathname).filter((p) => p.startsWith('/de/'))))].sort();
|
|
await overview.close();
|
|
for (const id of disabled) {
|
|
const page = await browser.newPage();
|
|
const response = await page.goto(`${base}/de/${id}.html`, { waitUntil: 'networkidle' });
|
|
if (!response || response.status() !== 200) failures.push(`disabled tool ${id}: no page /de/${id}.html`);
|
|
else if (await page.$('input[type=file]:visible')) failures.push(`disabled tool ${id}: still offers its file input`);
|
|
if (tools.includes(`/de/${id}.html`)) failures.push(`disabled tool ${id}: still linked from the overview`);
|
|
await page.close();
|
|
}
|
|
if (tools.length < 50) failures.push(`only ${tools.length} tool pages found on /de/`);
|
|
|
|
function probeFor(accept) {
|
|
if (/pdf/i.test(accept)) return { name: 'probe.pdf', mimeType: 'application/pdf', buffer: probes.pdf };
|
|
if (/image|png|jpe?g/i.test(accept)) return { name: 'probe.png', mimeType: 'image/png', buffer: probes.png };
|
|
return null;
|
|
}
|
|
|
|
async function run(path) {
|
|
const context = await browser.newContext({ viewport: { width: 412, height: 915 }, isMobile: true, hasTouch: true });
|
|
await context.addInitScript(() => {
|
|
window.__violations = [];
|
|
document.addEventListener('securitypolicyviolation', (e) =>
|
|
window.__violations.push(`${e.violatedDirective} ${e.blockedURI}`));
|
|
});
|
|
const page = await context.newPage();
|
|
const outside = new Set();
|
|
page.on('request', (request) => {
|
|
const url = request.url();
|
|
if (/^(data|blob|about):/.test(url)) return;
|
|
if (new URL(url).origin !== origin) outside.add(url);
|
|
});
|
|
page.on('dialog', (dialog) => dialog.dismiss().catch(() => {}));
|
|
try {
|
|
await page.goto(base + path, { waitUntil: 'networkidle', timeout: 30000 });
|
|
const input = await page.$('input[type=file]');
|
|
const file = input && probeFor((await input.getAttribute('accept')) || '');
|
|
if (file) {
|
|
await input.setInputFiles(file);
|
|
await page.waitForTimeout(2500);
|
|
const button = await page.$('#process-btn:visible');
|
|
if (button) {
|
|
await button.click({ timeout: 5000 }).catch(() => {});
|
|
await page.waitForTimeout(5000);
|
|
}
|
|
}
|
|
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
|
const violations = await page.evaluate(() => window.__violations);
|
|
for (const v of violations) failures.push(`${path}: CSP violation ${v}`);
|
|
} catch (error) {
|
|
// A page that cannot be driven is not a leak; it is reported, not failed.
|
|
console.log(` note ${path}: ${error.message.split('\n')[0]}`);
|
|
}
|
|
for (const url of outside) failures.push(`${path}: request to ${url}`);
|
|
await context.close();
|
|
return outside.size;
|
|
}
|
|
|
|
let done = 0;
|
|
const queue = [...tools];
|
|
await Promise.all(Array.from({ length: 4 }, async () => {
|
|
while (queue.length) {
|
|
const path = queue.shift();
|
|
await run(path);
|
|
done += 1;
|
|
}
|
|
}));
|
|
await browser.close();
|
|
|
|
console.log(`zero-hosts: ${done} tool pages driven against ${origin}, CSP: ${csp ? 'present' : 'missing'}`);
|
|
if (failures.length) {
|
|
for (const f of [...new Set(failures)]) console.log(` FAIL ${f}`);
|
|
console.log(`zero-hosts: ${new Set(failures).size} failure(s)`);
|
|
process.exit(1);
|
|
}
|
|
console.log('zero-hosts: nothing left the origin');
|