Files
magdevandClaude Opus 5.5 47138ddfbe BentoPDF for bau-pm, without third-party hosts (bau-pm BAU-119)
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>
2026-10-10 19:36:02 +02:00

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');