Skip to content

Commit 1fee13d

Browse files
fix(service-print): bind the local placement's browser-server control socket to 127.0.0.1 (#24155)
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. Its `chromium.launchServer` call passed no `host`, so the control socket's bind rested on playwright-core's default (`localhost` in the pinned 1.63.0). It now passes `host: '127.0.0.1'`, the hardening PR #23811's ACCEPT set as its condition. - `packages/services/service-print/src/chromium-driver.ts` - `launchServer({ executablePath, headless: true, timeout, host: LOCAL_CONTROL_HOST })`, where `LOCAL_CONTROL_HOST = '127.0.0.1'` is a module-private constant (not exported). - The `ChromiumLauncher` type declares `host: string` on `launchServer`'s options. It is required, so dropping the option from the call is also a type error (measured below: `TS2345`). - The header comment's **local** bullet now says the control socket is bound to IPv4 loopback by this package and why, and that a host without that address cannot open it. - One `patch` changeset for `@objectstack/service-print`. Not touched: the `port`, the remote placement, the plugin's options and environment variables, the exports, the `playwright-core` version. ## Premise, checked on `origin/main` `747c8e9c48` before any edit - `chromium-driver.ts:220` called `launchServer({ executablePath: …, headless: true, timeout: timeoutMs })` with no `host`, and the launcher type at `:105` had no `host` field. - `playwright-core` is pinned exact at `1.63.0`. In the installed copy, `lib/coreBundle.js:10175-10177` reads `listen(port = 0, hostname, defaultPath) { … hostname ??= "localhost"`, and `:57206-57208` shows `launchServer` calling `server.listen(options.port, options.host)`. - `:10181-10193`: `wsEndpoint()` is built from `server.address()`, the bound socket's own address, so it names where the socket really listens. - `:8978-8986` (`computeAllowedHosts`): an explicit `127.0.0.1` keeps the Host and Origin allow-list on, with the same loopback set `localhost` got. ## Pins 1. **Test Core** (`chromium-driver.unit.test.ts`, no browser). `stagedLauncher` now also returns its `launchServer` mock. A new test runs the boot probe and then a render, and asserts the `host` option of each `launchServer` call equals `['127.0.0.1', '127.0.0.1']`. 2. **Real Chromium** (`chromium-driver.test.ts`, inside the existing `describe.skipIf(!CHROMIUM)` block). A local-placement test drives the shipped driver through a launcher that wraps the real playwright-core `chromium` and records each started server's `wsEndpoint()`. It asserts that endpoint's hostname is `127.0.0.1`. Where no Chromium is installed it skips with the file's existing warning, as before. - **Chromium was present in this run**: `/opt/pw-browsers/chromium`, found through `PLAYWRIGHT_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 from `HEAD`. Each restore was proven by blob: after restore, the blob was `84907ccbc2e2`, equal to the `HEAD` blob, and `git diff HEAD` was empty. - **A: drop the option.** The anchor `host: LOCAL_CONTROL_HOST,` went from 1 hit to 0 on disk, blob `84907ccbc2e2` to `f79108c782c5`. - The unit pin went **red**: `expected [ undefined, undefined ] to deeply equal [ '127.0.0.1', '127.0.0.1' ]`. - The real-Chromium pin stayed **green**. That was expected: this host's `/etc/hosts` maps `localhost` to `127.0.0.1` only, 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; }'`. - **B: rebind to another loopback address.** `'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 `EADDRNOTAVAIL` when a bind address is not assigned to any local interface. A host with no IPv4 loopback gives that answer for `127.0.0.1`. I used an address this host does not have, `192.0.2.1` (TEST-NET-1), in place of the missing `127.0.0.1`, with the real Chromium. No network configuration was changed. - `chromium.launchServer({ …, host: '192.0.2.1' })` rejects with `listen EADDRNOTAVAIL: address not available 192.0.2.1`. It does not fall back to any other address. - The shipped driver's probe, the path `PrintServicePlugin.start()` takes, throws `Chromium could not be reached at the local executable /opt/pw-browsers/chromium: listen EADDRNOTAVAIL: address not available 192.0.2.1`. - The rest is read from code, not measured: `start()` logs that reason as `printing is off until this is fixed — …` (`print-service-plugin.ts:94-97`) and `status()` answers `unavailable`. A print is refused with `SERVICE_UNAVAILABLE` / `503` once its re-probe fails (`print-service.ts:250-276`). - **A limit, measured.** playwright-core launches Chromium before it binds the socket (`coreBundle.js:57186-57207`), and a failed bind does not close that browser. - 1.5 s after the rejection, 6 processes were still descended from the measuring node process, and the process did not exit on its own. - `timeout` stopped it at 120 s. playwright's own SIGTERM handler then cleaned up the browsers, and no orphan was left. - In a long-running server on such a host, each failed open would therefore leave one Chromium running: one for the boot probe, and one for each print's re-probe. - The same was true before this change on any host where binding `localhost` fails. This PR does not change that library behaviour. **Stated limits, not measured here:** - A real host without `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. - A host whose `localhost` resolves to `::1` first. This kernel has no IPv6: binding `::1` answers `EAFNOSUPPORT`. - Node's `listen` binds the first address a name resolves to, so the old default would have bound `[::1]` on such a host. The socket moves to `127.0.0.1` there. - The driver still connects, because it dials whatever `wsEndpoint()` names. - How rare such a host is. Linux assigns `127.0.0.1/8` to a loopback device when it comes up (`net/ipv4/devinet.c`; a fresh network namespace here bound `127.0.0.1` too). 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=verbose` exited 0. - `check-dts-emitted: @objectstack/service-print - 2/2 declared declaration file(s) present`. - 6 test files, 81 tests passed, including both new pins. - `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/objectstack` derived 63 commands. I ran all of them sequentially and recorded each exit code before any pipe. - 62 exited 0. - `pnpm check:dual-build-cjs-loads` exited 3, `PREREQUISITE NOT MET`: it reads every package's `dist/`, and only this package's closure is built here. That makes it **NOT MEASURED** locally. `Lint & Repo Gates` runs it in CI. The `require()` of this package's CJS build above is the direct reading for the one package touched. - `--ran` reconciliation: `63 derived famil(ies) accounted for — 62 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`, 0 unrun. - The 4 roster gates whose rosters sit under these paths were also run, each with exit 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`. - CI's check-runs are not in yet as this PR opens; the os-dev report on the card carries them. ## Acceptance notes - **playwright-core leaves a launched Chromium running when its bind fails** (measured above). This is library behaviour, it already existed before this PR, and it is reachable only on a host where the bind fails. No public-door reach was measured on a real deployment, so it is noted here and not filed. Carrier: none. - **"Module-private".** The claim called `ChromiumLauncher` module-private. Strictly, the module exports it as a type (`export type { ChromiumLauncher, … }`, used by the two test files). It is not re-exported from `src/index.ts`, and it is absent from `dist/index.d.ts` and `dist/index.d.cts` (0 hits; the control, `PrintServicePlugin`, has 5). So making `host` required changes no published type, and `Clause-②: no` holds. --- _Generated by [Claude Code](https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 135e19e commit 1fee13d

4 files changed

Lines changed: 72 additions & 9 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-print': patch
3+
---
4+
5+
Clause-②: no
6+
7+
fix(service-print): the local placement binds its browser server's control socket to `127.0.0.1` itself, instead of relying on `playwright-core`'s default host
8+
9+
- **What changed.** With `OS_PRINT_CHROMIUM_PATH` (option `chromiumPath`), each render and each boot probe starts a Chromium browser server and connects to it. That server's control socket is now opened with `host: '127.0.0.1'`. Before, no host was passed, so the bind followed `playwright-core`'s default (`localhost`, loopback only in the pinned 1.63.0). Whoever reaches that socket and knows its path drives a browser signed in as the requesting user. How far it reaches is now set by this package, and a `playwright-core` upgrade cannot widen it without a diff here.
10+
- **What it means for a deployment.** Nothing changes on a host whose `localhost` is `127.0.0.1`, which is the usual case. On a host with no IPv4 loopback address, the local placement cannot open the socket. Its boot probe logs `Chromium could not be reached at the local executable …: listen EADDRNOTAVAIL …` and printing stays off. The socket is never bound to another address instead. On such a host, use the remote placement (`OS_PRINT_BROWSER_ENDPOINT`).
11+
- **Unchanged:** the remote placement, the port (still random), every option and environment variable, and the exports.

‎packages/services/service-print/src/chromium-driver.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
88
import { join } from 'node:path';
99
import { inflateSync } from 'node:zlib';
1010
import type { PluginContext } from '@objectstack/core';
11-
import { createChromiumDriver, type ChromiumDriver } from './chromium-driver.js';
11+
import { createChromiumDriver, type ChromiumDriver, type ChromiumLauncher } from './chromium-driver.js';
1212
import { PRINT_SERVICE, PrintServicePlugin } from './print-service-plugin.js';
1313
import type { PrintService } from './print-service.js';
1414
import type { PrintSessionCookie } from './principal-session.js';
@@ -307,6 +307,31 @@ describe.skipIf(!CHROMIUM)('the Chromium engine on both placements', () => {
307307
});
308308
}
309309

310+
it('local: the control socket the driver opens is bound to IPv4 loopback — its wsEndpoint names 127.0.0.1', async () => {
311+
// The real launcher, recording each server it starts. playwright-core builds
312+
// `wsEndpoint()` from the listening socket's own `address()`, so it names where
313+
// the socket is actually bound, not the host that was asked for.
314+
const { chromium } = (await import('playwright-core')) as unknown as { chromium: ChromiumLauncher };
315+
const endpoints: string[] = [];
316+
const recording: ChromiumLauncher = {
317+
launchServer: async (options) => {
318+
const server = await chromium.launchServer(options);
319+
endpoints.push(server.wsEndpoint());
320+
return server;
321+
},
322+
connect: (wsEndpoint, options) => chromium.connect(wsEndpoint, options),
323+
connectOverCDP: (endpoint, options) => chromium.connectOverCDP(endpoint, options),
324+
};
325+
const driver = createChromiumDriver(
326+
{ placement: 'local', target: CHROMIUM!, consoleUrl: `${fixture.origin}/_console` },
327+
async () => recording,
328+
);
329+
const { version } = await driver.probe(30_000);
330+
expect(version).toMatch(/^\d+\./);
331+
expect(endpoints).toHaveLength(1);
332+
expect(new URL(endpoints[0]!).hostname).toBe('127.0.0.1');
333+
}, 60_000);
334+
310335
it('remote: the render leaves no context (and so no session cookie) in a browser it does not own, and leaves it running', async () => {
311336
const driver = placements().find((p) => p.label === 'remote')!.driver;
312337
await driver.renderPdf({ url: `${fixture.origin}${RECORD_PATH}`, cookie: COOKIE, timeoutMs: 30_000 });

‎packages/services/service-print/src/chromium-driver.ts‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ import type { PrintSessionCookie } from './principal-session.js';
99
* `playwright-core` (pinned exact on this package only), in either placement
1010
* {@link ResolvedPrintPlacement} names.
1111
*
12-
* - **local** — `chromium.launchServer({ executablePath })` and a connection to
13-
* it, one browser process per render. Disposed by closing the connection
14-
* and the server; a process whose close does not settle within
15-
* {@link CLOSE_GRACE_MS} is killed.
12+
* - **local** — `chromium.launchServer({ executablePath, host: '127.0.0.1' })`
13+
* and a connection to it, one browser process per render. Disposed by
14+
* closing the connection and the server; a process whose close does not
15+
* settle within {@link CLOSE_GRACE_MS} is killed. The server's control
16+
* socket is bound to the IPv4 loopback address by THIS package
17+
* ({@link LOCAL_CONTROL_HOST}), never left to playwright-core's default
18+
* host: whoever reaches that socket and knows its path drives a browser
19+
* signed in as the requesting user, so how far it reaches is decided here,
20+
* where a version bump cannot change it without a diff. A host with no IPv4
21+
* loopback address cannot open it, and the open fails; it is never bound
22+
* anywhere else instead.
1623
* - **remote** — `chromium.connectOverCDP(endpoint)`, one connection per
1724
* render. The context this render opened is closed first, so its cookies
1825
* leave the remote browser with it; then the connection is closed, which
@@ -102,14 +109,18 @@ interface ServerHandle {
102109
kill(): Promise<void>;
103110
}
104111
interface ChromiumLauncher {
105-
launchServer(options: { executablePath: string; headless: boolean; timeout: number }): Promise<ServerHandle>;
112+
/** `host` is required here so the local placement can never open its control socket on the library's default host. */
113+
launchServer(options: { executablePath: string; headless: boolean; timeout: number; host: string }): Promise<ServerHandle>;
106114
connect(wsEndpoint: string, options: { timeout: number }): Promise<BrowserHandle>;
107115
connectOverCDP(endpoint: string, options: { timeout: number }): Promise<BrowserHandle>;
108116
}
109117

110118
/** How long each close in a cleanup may take before the driver stops waiting for it. */
111119
export const CLOSE_GRACE_MS = 2_000;
112120

121+
/** The address the local placement binds its browser server's control socket to: IPv4 loopback, and only that. */
122+
const LOCAL_CONTROL_HOST = '127.0.0.1';
123+
113124
/** Whether `promise` settles within `ms`; it is never awaited past that. */
114125
async function settlesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
115126
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -217,7 +228,12 @@ class PlaywrightChromiumDriver implements ChromiumDriver {
217228
const browser = await chromium.connectOverCDP(this.resolved.target, { timeout: timeoutMs });
218229
return { browser, dispose: async () => void (await settlesWithin(browser.close(), grace)) };
219230
}
220-
const server = await chromium.launchServer({ executablePath: this.resolved.target, headless: true, timeout: timeoutMs });
231+
const server = await chromium.launchServer({
232+
executablePath: this.resolved.target,
233+
headless: true,
234+
timeout: timeoutMs,
235+
host: LOCAL_CONTROL_HOST,
236+
});
221237
let browser: BrowserHandle;
222238
try {
223239
browser = await chromium.connect(server.wsEndpoint(), { timeout: timeoutMs });

‎packages/services/service-print/src/chromium-driver.unit.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,13 @@ function stagedLauncher(stage: Stage) {
6868
closed.killed++;
6969
}),
7070
};
71+
const launchServer = vi.fn(async (_options: Record<string, unknown>) => server);
7172
const launcher = {
72-
launchServer: vi.fn(async () => server),
73+
launchServer,
7374
connect: vi.fn(stage.connect ?? (async () => browser)),
7475
connectOverCDP: vi.fn(stage.connect ?? (async () => browser)),
7576
} as unknown as ChromiumLauncher;
76-
return { launcher: async () => launcher, closed, page, browser };
77+
return { launcher: async () => launcher, closed, page, browser, launchServer };
7778
}
7879

7980
async function refusalOf(p: Promise<unknown>): Promise<{ code?: unknown; status?: unknown; message: string }> {
@@ -86,6 +87,16 @@ async function refusalOf(p: Promise<unknown>): Promise<{ code?: unknown; status?
8687
throw new Error('expected a refusal, the call resolved');
8788
}
8889

90+
describe('the local placement binds its control socket to loopback itself', () => {
91+
it('launchServer receives host 127.0.0.1 on the boot probe and on every render, never the library default', async () => {
92+
const { launcher, launchServer } = stagedLauncher({});
93+
const driver = createChromiumDriver({ placement: 'local', target: '/usr/bin/chromium', consoleUrl: 'x' }, launcher);
94+
await driver.probe(5_000);
95+
await driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 5_000 });
96+
expect(launchServer.mock.calls.map(([options]) => options.host)).toEqual(['127.0.0.1', '127.0.0.1']);
97+
});
98+
});
99+
89100
describe('the driver redacts the session cookie from an engine error', () => {
90101
it('an engine error carrying the cookie value answers INTERNAL_ERROR / 500 with it replaced by [redacted]', async () => {
91102
const { launcher, closed } = stagedLauncher({

0 commit comments

Comments
 (0)