Bind local preview servers to loopback only
Some checks are pending
build-gate / build (push) Waiting to run
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:
parent
a1005869ce
commit
0296b03aa7
2 changed files with 103 additions and 4 deletions
97
internal/check-no-public-binds.mjs
Normal file
97
internal/check-no-public-binds.mjs
Normal 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.')
|
||||
10
package.json
10
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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue