Repository navigation
fix(service-print): bind the local placement's browser-server control socket to 127.0.0.1 - #24155
Conversation
…0.0.1 The local placement's chromium.launchServer call passed no host, so its control socket's bind followed playwright-core's default (localhost). Pass host: '127.0.0.1' explicitly, declare host on the module-private launcher type, and say so in the driver header. Pins: the staged launcher asserts launchServer receives host 127.0.0.1 on the boot probe and on a render; the real-Chromium suite asserts the started server's wsEndpoint names 127.0.0.1. Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
…loopback Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
…int-loopback-host
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fb5fb3adf9a73332eb99ae157dca0a98bfcfa1be && git checkout fb5fb3adf9a73332eb99ae157dca0a98bfcfa1be
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6dede7a51acd11539a50a2427f14bd6ac360627a 8ba6a17be0875524dbab9f32185f13d8789e056a && git checkout -B drift-repro 6dede7a51acd11539a50a2427f14bd6ac360627a && git merge --no-ff 8ba6a17be0875524dbab9f32185f13d8789e056a
node scripts/docs-audit/affected-docs.mjs --json 6dede7a51acd11539a50a2427f14bd6ac360627a |
席位核对 · ACCEPT ·
|
Fixes #23968
Clause-②: no
What
The print service's local placement (
OS_PRINT_CHROMIUM_PATH/chromiumPath) starts a playwright-core browser server for every render and every boot probe, then connects to it. Itschromium.launchServercall passed nohost, so the control socket's bind rested on playwright-core's default (localhostin the pinned 1.63.0). It now passeshost: '127.0.0.1', the hardening PR #23811's ACCEPT set as its condition.packages/services/service-print/src/chromium-driver.tslaunchServer({ executablePath, headless: true, timeout, host: LOCAL_CONTROL_HOST }), whereLOCAL_CONTROL_HOST = '127.0.0.1'is a module-private constant (not exported).ChromiumLaunchertype declareshost: stringonlaunchServer's options. It is required, so dropping the option from the call is also a type error (measured below:TS2345).patchchangeset for@objectstack/service-print.Not touched: the
port, the remote placement, the plugin's options and environment variables, the exports, theplaywright-coreversion.Premise, checked on
origin/main747c8e9c48before any editchromium-driver.ts:220calledlaunchServer({ executablePath: …, headless: true, timeout: timeoutMs })with nohost, and the launcher type at:105had nohostfield.playwright-coreis pinned exact at1.63.0. In the installed copy,lib/coreBundle.js:10175-10177readslisten(port = 0, hostname, defaultPath) { … hostname ??= "localhost", and:57206-57208showslaunchServercallingserver.listen(options.port, options.host).:10181-10193:wsEndpoint()is built fromserver.address(), the bound socket's own address, so it names where the socket really listens.:8978-8986(computeAllowedHosts): an explicit127.0.0.1keeps the Host and Origin allow-list on, with the same loopback setlocalhostgot.Pins
chromium-driver.unit.test.ts, no browser).stagedLaunchernow also returns itslaunchServermock. A new test runs the boot probe and then a render, and asserts thehostoption of eachlaunchServercall equals['127.0.0.1', '127.0.0.1'].chromium-driver.test.ts, inside the existingdescribe.skipIf(!CHROMIUM)block). A local-placement test drives the shipped driver through a launcher that wraps the real playwright-corechromiumand records each started server'swsEndpoint(). It asserts that endpoint's hostname is127.0.0.1. Where no Chromium is installed it skips with the file's existing warning, as before./opt/pw-browsers/chromium, found throughPLAYWRIGHT_BROWSERS_PATH. The test ran and passed.Ablation
The fix was committed first. Each mutation went through
scripts/ablation-replace.mjs, which checks the anchor count on disk, inside a trap that restores fromHEAD. Each restore was proven by blob: after restore, the blob was84907ccbc2e2, equal to theHEADblob, andgit diff HEADwas empty.host: LOCAL_CONTROL_HOST,went from 1 hit to 0 on disk, blob84907ccbc2e2tof79108c782c5.expected [ undefined, undefined ] to deeply equal [ '127.0.0.1', '127.0.0.1' ]./etc/hostsmapslocalhostto127.0.0.1only, so the library default lands on the same address. The real pin checks where the socket is bound, and the unit pin is the one that holds the explicit option.tsc(same mutation, separate run):error TS2345: Argument of type '{ executablePath: string; headless: true; timeout: number; }' is not assignable to parameter of type '{ executablePath: string; headless: boolean; timeout: number; host: string; }'.'127.0.0.1'became'127.0.0.2', to show the real pin reads the bound address. The real-Chromium pin went red:expected '127.0.0.2' to be '127.0.0.1'.A host with no IPv4 loopback (card item 5)
Measured, by a stand-in. The kernel answers
EADDRNOTAVAILwhen a bind address is not assigned to any local interface. A host with no IPv4 loopback gives that answer for127.0.0.1. I used an address this host does not have,192.0.2.1(TEST-NET-1), in place of the missing127.0.0.1, with the real Chromium. No network configuration was changed.chromium.launchServer({ …, host: '192.0.2.1' })rejects withlisten EADDRNOTAVAIL: address not available 192.0.2.1. It does not fall back to any other address.PrintServicePlugin.start()takes, throwsChromium could not be reached at the local executable /opt/pw-browsers/chromium: listen EADDRNOTAVAIL: address not available 192.0.2.1.start()logs that reason asprinting is off until this is fixed — …(print-service-plugin.ts:94-97) andstatus()answersunavailable. A print is refused withSERVICE_UNAVAILABLE/503once its re-probe fails (print-service.ts:250-276).coreBundle.js:57186-57207), and a failed bind does not close that browser.timeoutstopped it at 120 s. playwright's own SIGTERM handler then cleaned up the browsers, and no orphan was left.localhostfails. This PR does not change that library behaviour.Stated limits, not measured here:
127.0.0.1. I tried to set one up by deleting the address inside a private network namespace, and this session's permission classifier refused that step.localhostresolves to::1first. This kernel has no IPv6: binding::1answersEAFNOSUPPORT.listenbinds the first address a name resolves to, so the old default would have bound[::1]on such a host. The socket moves to127.0.0.1there.wsEndpoint()names.127.0.0.1/8to a loopback device when it comes up (net/ipv4/devinet.c; a fresh network namespace here bound127.0.0.1too). So a host lacks it only when someone removed it.Tests (at the final head
8ba6a17be0)pnpm --filter @objectstack/service-print build && … typecheck && … exec vitest run --reporter=verboseexited 0.check-dts-emitted: @objectstack/service-print - 2/2 declared declaration file(s) present.node -e "require('…/service-print/dist/index.cjs')"loads, with 7 exports.Gates (local, at
8ba6a17be0)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 63 commands. I ran all of them sequentially and recorded each exit code before any pipe.pnpm check:dual-build-cjs-loadsexited 3,PREREQUISITE NOT MET: it reads every package'sdist/, and only this package's closure is built here. That makes it NOT MEASURED locally.Lint & Repo Gatesruns it in CI. Therequire()of this package's CJS build above is the direct reading for the one package touched.--ranreconciliation:63 derived famil(ies) accounted for — 62 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3), 0 unrun.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity.Acceptance notes
ChromiumLaunchermodule-private. Strictly, the module exports it as a type (export type { ChromiumLauncher, … }, used by the two test files). It is not re-exported fromsrc/index.ts, and it is absent fromdist/index.d.tsanddist/index.d.cts(0 hits; the control,PrintServicePlugin, has 5). So makinghostrequired changes no published type, andClause-②: noholds.Generated by Claude Code