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>
This commit is contained in:
commit
47138ddfbe
19 files changed
+1733
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# The gate's own image (test/zero-hosts.mjs): Chromium and playwright-core,
|
||||
# with the test files and the glue baked in, because the CI runner passes the
|
||||
# host's Docker socket through and a mounted workspace would be empty.
|
||||
FROM public.ecr.aws/docker/library/node:20-alpine
|
||||
RUN apk add --no-cache chromium
|
||||
WORKDIR /gate
|
||||
RUN npm init -y >/dev/null && npm install --no-audit --no-fund --silent playwright-core@1.63.0
|
||||
COPY test/ ./test/
|
||||
COPY glue/ ./glue/
|
||||
RUN node --check glue/handoff.js
|
||||
ENV PLAYWRIGHT_CORE=/gate/node_modules/playwright-core/index.mjs \
|
||||
CHROMIUM=/usr/bin/chromium-browser
|
||||
CMD ["node", "test/zero-hosts.mjs"]
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<</Type/Catalog/Pages 2 0 R>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Length 49>>stream
|
||||
BT /F1 24 Tf 72 760 Td (Regierapport Probe) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000054 00000 n
|
||||
0000000105 00000 n
|
||||
0000000217 00000 n
|
||||
0000000313 00000 n
|
||||
trailer<</Size 6/Root 1 0 R>>
|
||||
startxref
|
||||
376
|
||||
%%EOF
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 75 B |
@@ -0,0 +1,139 @@
|
||||
// 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');
|
||||
Reference in new issue
Block a user