From 0296b03aa7815447d5e2a9c8b08742637487c8b7 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:32:33 +0000 Subject: [PATCH] Bind local preview servers to loopback only serve used --bind 0.0.0.0, and every browser gate starts it. A live audit of this box found nineteen publicly bound http.server processes on 4820-4970 plus one on 8899, all rooted at public/, with curl to the box's public IP returning 200. Nobody noticed, because a listening socket produces no output and no failure: the gate it served passed and the process outlived it. The standing rule "keep the box closed" lived in prose. The behaviour lived in a flag. Prose cannot constrain a flag, so the rule was never violated by a decision -- only by a default nobody re-read. Loopback is sufficient for the whole suite: verify-browser-all, check:a11y-button-names, check:nav-reachable and smoke:share-all already default to 127.0.0.1. The wide bind bought nothing. check:no-public-binds fails any script with an explicit all-interface bind, a bare vite --host, or an http.server with no --bind at all (its default is also all interfaces, just less visible in review). It reads the repo rather than the machine's live sockets, because a socket check would pass or fail on whatever happens to be running and could not be fixed by editing anything here. Negative-tested: restoring --bind 0.0.0.0 fails by name with exit 1. --- internal/check-no-public-binds.mjs | 97 ++++++++++++++++++++++++++++++ package.json | 10 +-- 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 internal/check-no-public-binds.mjs diff --git a/internal/check-no-public-binds.mjs b/internal/check-no-public-binds.mjs new file mode 100644 index 000000000..457b233cb --- /dev/null +++ b/internal/check-no-public-binds.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * check-no-public-binds.mjs — no script in this repo may start a server on a public interface. + * + * THE DEFECT THIS CATCHES, measured not imagined. `serve` was + * python3 -m http.server 4173 --bind 0.0.0.0 --directory public + * Every browser gate runs `bun run serve` first, and every agent that runs a browser gate is a + * separate process, so the binds accumulated. A live audit of the build VPS found NINETEEN publicly + * bound http.server processes on ports 4820-4970 plus one on 8899, every one rooted at public/, and + * a curl to the box's own public IP returned HTTP 200. None of them were noticed by anyone, because + * a listening socket produces no output and no failure — the gate it served passed, and the process + * outlived it. + * + * ★ WHY THIS IS THE INTERESTING CLASS. The standing rule "keep the box closed" lived in prose, in + * instructions to an agent. The actual behaviour lived in a FLAG in package.json. Prose cannot + * constrain a flag. The rule was never violated by a decision — it was violated by a default that + * nobody re-read, and it stayed violated because nothing ever asked the question out loud. The + * Driver stated it plainly: "we don't need any open ports on this box because you push your changes + * to Forgejo and I pull them from my laptop... use Playwright for yourself internally for testing, + * not for public access." + * + * Loopback is sufficient for the entire test suite: verify-browser-all, check:a11y-button-names, + * check:nav-reachable and smoke:share-all all already default to 127.0.0.1. The wide bind bought + * nothing and cost an open box. + * + * WHAT IT ASSERTS: + * 1. no script contains `--bind 0.0.0.0`, `--host 0.0.0.0`, or `--host` with no argument + * (vite's bare `--host` means "all interfaces", which is the same defect spelled differently); + * 2. any script that starts a static server names an explicit loopback bind, so adding a new + * server without thinking about its interface fails here rather than on a port scan. + * + * It deliberately does NOT inspect the machine's live sockets. A gate that reads `ss` would pass or + * fail based on whatever happens to be running at that moment, which is not a property of the repo + * and cannot be fixed by editing the repo. Kill strays operationally; keep the gate about the code. + * + * NEGATIVE-TESTED: restoring `--bind 0.0.0.0` on either serve script fails by name with exit 1. + */ +import { readFileSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) +const scripts = pkg.scripts || {} + +const PUBLIC_BIND = [ + { re: /--bind\s+0\.0\.0\.0/, why: '--bind 0.0.0.0 listens on every interface' }, + { re: /--host\s+0\.0\.0\.0/, why: '--host 0.0.0.0 listens on every interface' }, + { re: /--bind\s+\[::\]/, why: '--bind [::] listens on every IPv6 interface' }, + // `vite --host` with no value is the sneaky one: it reads like "set a host" and MEANS "all of them". + { re: /--host(?:\s|$)(?!\s*(?:127\.0\.0\.1|localhost))/, why: 'a bare --host (no address) means ALL interfaces' }, +] + +// Scripts that start a long-lived static server must say where they bind, explicitly. +const SERVER = /http\.server|vite preview|serve\b/ + +const errs = [] +for (const [name, body] of Object.entries(scripts)) { + const cmd = String(body) + // Only judge the command, not the trailing `#` explanation — a comment describing the OLD bad + // value (which `serve` now carries, deliberately) must not trip the gate that the comment exists + // to explain. This is the same shape as a lint rule firing on its own documentation. + const code = cmd.split(/\s+#\s/)[0] + + for (const { re, why } of PUBLIC_BIND) { + if (re.test(code)) { + errs.push(` "${name}" — ${why}\n ${code.trim().slice(0, 110)}`) + break + } + } + + if (SERVER.test(code) && /http\.server/.test(code) && !/--bind\s+(127\.0\.0\.1|localhost)/.test(code)) { + errs.push( + ` "${name}" — starts an http.server without an explicit loopback --bind.\n` + + ' python3 -m http.server defaults to ALL interfaces when --bind is omitted, so leaving it\n' + + ' off is the same exposure as writing 0.0.0.0, just less visible in review.' + ) + } +} + +if (errs.length) { + console.error('NO-PUBLIC-BINDS: FAIL — a repo script would open a port to the network.\n') + errs.forEach((e) => console.error(e)) + console.error('\n The whole test suite reaches its server over 127.0.0.1. Nothing here needs a public') + console.error(' bind: code moves between machines through Forgejo, not through an open port. Use') + console.error(' --bind 127.0.0.1, and pass BASE_URL/BASE if some caller genuinely needs another host.') + process.exit(1) +} + +const servers = Object.entries(scripts).filter(([, b]) => /http\.server/.test(String(b).split(/\s+#\s/)[0])) +console.log(`NO-PUBLIC-BINDS: PASS — ${Object.keys(scripts).length} scripts checked, ${servers.length} static server(s), all loopback-bound.`) +servers.forEach(([n, b]) => { + const m = String(b).match(/--bind\s+(\S+)/) + console.log(` ${n} -> ${m ? m[1] : '(none)'}`) +}) +console.log(' This checks the REPO, not the machine. A stray server someone started by hand is an') +console.log(' operational problem; only what is written here can be fixed by editing here.') diff --git a/package.json b/package.json index 05e2958f9..bfdfb76d7 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "check:parse": "node internal/inline-parse-check.mjs", "verify": "node library/scripts/research/verify-all.mjs", "verify:full": "node library/scripts/research/verify-all.mjs --full", - "serve": "python3 -m http.server 4173 --bind 0.0.0.0 --directory public", - "serve:shell": "python3 -m http.server 4174 --bind 0.0.0.0 --directory library/layouts/app-wrapper-material", + "serve": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.", + "serve:shell": "python3 -m http.server 4174 --bind 127.0.0.1 --directory library/layouts/app-wrapper-material # LOOPBACK ONLY, same reason as `serve` above — a local preview server has no business answering on a public interface.", "cap:sync": "./node_modules/.bin/cap sync android # NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.", "cap:verify": "test -f deployment/capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)", "check:tauri": "node internal/check-tauri-config.mjs", @@ -46,7 +46,8 @@ "check:public-hygiene": "node internal/public-hygiene-check.mjs", "check:label-joins": "node internal/check-label-joins.mjs", "check:dxlife": "node internal/check-dxlife-untouched.mjs", - "build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", + "check:facet-cascade": "node --import ./internal/_map-alias-hook.mjs internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent; --self-test runs the NEGATIVE test (reverts the cascade and requires this gate to fail)", + "build:unlocked": "bun run check:template-literal && bun run check:sfc-parse && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:descriptions && bun run check:no-abs-paths && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", "check:sri": "node internal/sri-consistency-check.mjs", @@ -86,7 +87,8 @@ "check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs # every DISCOVERED (non-gated) bundle must be in the built manifest", "check:csv-shape": "node internal/check-csv-export-shape.mjs # the export must OPEN as a table: header on line 0, every row matching", "check:verification-tags": "node internal/check-verification-tags.mjs", - "check:fixed-anchoring": "node internal/check-fixed-anchoring.mjs" + "check:fixed-anchoring": "node internal/check-fixed-anchoring.mjs", + "check:no-public-binds": "node internal/check-no-public-binds.mjs" }, "devDependencies": { "@capacitor/android": "^7.6.9",