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