Bind local preview servers to loopback only
Some checks are pending
build-gate / build (push) Waiting to run

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.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 17:32:33 +00:00
parent a1005869ce
commit 0296b03aa7
2 changed files with 103 additions and 4 deletions

View file

@ -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.')

View file

@ -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",