From eb36365d562a3ab41da95b06e903bb7dc6e0e666 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 05:26:02 +0000 Subject: [PATCH 1/2] fix(service-print): bind the local placement's control socket to 127.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 --- .changeset/23968-print-local-loopback-host.md | 11 ++++++++ .../service-print/src/chromium-driver.test.ts | 27 +++++++++++++++++- .../service-print/src/chromium-driver.ts | 28 +++++++++++++++---- .../src/chromium-driver.unit.test.ts | 15 ++++++++-- 4 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 .changeset/23968-print-local-loopback-host.md diff --git a/.changeset/23968-print-local-loopback-host.md b/.changeset/23968-print-local-loopback-host.md new file mode 100644 index 00000000000..03f5f9ca066 --- /dev/null +++ b/.changeset/23968-print-local-loopback-host.md @@ -0,0 +1,11 @@ +--- +'@objectstack/service-print': patch +--- + +Clause-②: no + +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 + +- **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 change it without a diff here. +- **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 whose `localhost` resolves to `::1` first, the socket now listens on `127.0.0.1` instead of `[::1]`, and the driver still connects to wherever it listens. On a host with no IPv4 loopback address, the local placement cannot open the socket. The boot probe reports the engine as unreachable, naming the address it could not bind, and a print is refused. The socket is never bound to another address instead. If that is your host, use the remote placement (`OS_PRINT_BROWSER_ENDPOINT`). +- **Unchanged:** the remote placement, the port (still random), every option and environment variable, and the exports. diff --git a/packages/services/service-print/src/chromium-driver.test.ts b/packages/services/service-print/src/chromium-driver.test.ts index a812d2717d2..9bda5c2f4e4 100644 --- a/packages/services/service-print/src/chromium-driver.test.ts +++ b/packages/services/service-print/src/chromium-driver.test.ts @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { inflateSync } from 'node:zlib'; import type { PluginContext } from '@objectstack/core'; -import { createChromiumDriver, type ChromiumDriver } from './chromium-driver.js'; +import { createChromiumDriver, type ChromiumDriver, type ChromiumLauncher } from './chromium-driver.js'; import { PRINT_SERVICE, PrintServicePlugin } from './print-service-plugin.js'; import type { PrintService } from './print-service.js'; import type { PrintSessionCookie } from './principal-session.js'; @@ -307,6 +307,31 @@ describe.skipIf(!CHROMIUM)('the Chromium engine on both placements', () => { }); } + it('local: the control socket the driver opens is bound to IPv4 loopback — its wsEndpoint names 127.0.0.1', async () => { + // The real launcher, recording each server it starts. playwright-core builds + // `wsEndpoint()` from the listening socket's own `address()`, so it names where + // the socket is actually bound, not the host that was asked for. + const { chromium } = (await import('playwright-core')) as unknown as { chromium: ChromiumLauncher }; + const endpoints: string[] = []; + const recording: ChromiumLauncher = { + launchServer: async (options) => { + const server = await chromium.launchServer(options); + endpoints.push(server.wsEndpoint()); + return server; + }, + connect: (wsEndpoint, options) => chromium.connect(wsEndpoint, options), + connectOverCDP: (endpoint, options) => chromium.connectOverCDP(endpoint, options), + }; + const driver = createChromiumDriver( + { placement: 'local', target: CHROMIUM!, consoleUrl: `${fixture.origin}/_console` }, + async () => recording, + ); + const { version } = await driver.probe(30_000); + expect(version).toMatch(/^\d+\./); + expect(endpoints).toHaveLength(1); + expect(new URL(endpoints[0]!).hostname).toBe('127.0.0.1'); + }, 60_000); + it('remote: the render leaves no context (and so no session cookie) in a browser it does not own, and leaves it running', async () => { const driver = placements().find((p) => p.label === 'remote')!.driver; await driver.renderPdf({ url: `${fixture.origin}${RECORD_PATH}`, cookie: COOKIE, timeoutMs: 30_000 }); diff --git a/packages/services/service-print/src/chromium-driver.ts b/packages/services/service-print/src/chromium-driver.ts index 1ee266530c1..84907ccbc2e 100644 --- a/packages/services/service-print/src/chromium-driver.ts +++ b/packages/services/service-print/src/chromium-driver.ts @@ -9,10 +9,17 @@ import type { PrintSessionCookie } from './principal-session.js'; * `playwright-core` (pinned exact on this package only), in either placement * {@link ResolvedPrintPlacement} names. * - * - **local** — `chromium.launchServer({ executablePath })` and a connection to - * it, one browser process per render. Disposed by closing the connection - * and the server; a process whose close does not settle within - * {@link CLOSE_GRACE_MS} is killed. + * - **local** — `chromium.launchServer({ executablePath, host: '127.0.0.1' })` + * and a connection to it, one browser process per render. Disposed by + * closing the connection and the server; a process whose close does not + * settle within {@link CLOSE_GRACE_MS} is killed. The server's control + * socket is bound to the IPv4 loopback address by THIS package + * ({@link LOCAL_CONTROL_HOST}), never left to playwright-core's default + * host: whoever reaches that socket and knows its path drives a browser + * signed in as the requesting user, so how far it reaches is decided here, + * where a version bump cannot change it without a diff. A host with no IPv4 + * loopback address cannot open it, and the open fails; it is never bound + * anywhere else instead. * - **remote** — `chromium.connectOverCDP(endpoint)`, one connection per * render. The context this render opened is closed first, so its cookies * leave the remote browser with it; then the connection is closed, which @@ -102,7 +109,8 @@ interface ServerHandle { kill(): Promise; } interface ChromiumLauncher { - launchServer(options: { executablePath: string; headless: boolean; timeout: number }): Promise; + /** `host` is required here so the local placement can never open its control socket on the library's default host. */ + launchServer(options: { executablePath: string; headless: boolean; timeout: number; host: string }): Promise; connect(wsEndpoint: string, options: { timeout: number }): Promise; connectOverCDP(endpoint: string, options: { timeout: number }): Promise; } @@ -110,6 +118,9 @@ interface ChromiumLauncher { /** How long each close in a cleanup may take before the driver stops waiting for it. */ export const CLOSE_GRACE_MS = 2_000; +/** The address the local placement binds its browser server's control socket to: IPv4 loopback, and only that. */ +const LOCAL_CONTROL_HOST = '127.0.0.1'; + /** Whether `promise` settles within `ms`; it is never awaited past that. */ async function settlesWithin(promise: Promise, ms: number): Promise { let timer: ReturnType | undefined; @@ -217,7 +228,12 @@ class PlaywrightChromiumDriver implements ChromiumDriver { const browser = await chromium.connectOverCDP(this.resolved.target, { timeout: timeoutMs }); return { browser, dispose: async () => void (await settlesWithin(browser.close(), grace)) }; } - const server = await chromium.launchServer({ executablePath: this.resolved.target, headless: true, timeout: timeoutMs }); + const server = await chromium.launchServer({ + executablePath: this.resolved.target, + headless: true, + timeout: timeoutMs, + host: LOCAL_CONTROL_HOST, + }); let browser: BrowserHandle; try { browser = await chromium.connect(server.wsEndpoint(), { timeout: timeoutMs }); diff --git a/packages/services/service-print/src/chromium-driver.unit.test.ts b/packages/services/service-print/src/chromium-driver.unit.test.ts index aaf522cfb63..b525bf9b66f 100644 --- a/packages/services/service-print/src/chromium-driver.unit.test.ts +++ b/packages/services/service-print/src/chromium-driver.unit.test.ts @@ -68,12 +68,13 @@ function stagedLauncher(stage: Stage) { closed.killed++; }), }; + const launchServer = vi.fn(async (_options: Record) => server); const launcher = { - launchServer: vi.fn(async () => server), + launchServer, connect: vi.fn(stage.connect ?? (async () => browser)), connectOverCDP: vi.fn(stage.connect ?? (async () => browser)), } as unknown as ChromiumLauncher; - return { launcher: async () => launcher, closed, page, browser }; + return { launcher: async () => launcher, closed, page, browser, launchServer }; } async function refusalOf(p: Promise): Promise<{ code?: unknown; status?: unknown; message: string }> { @@ -86,6 +87,16 @@ async function refusalOf(p: Promise): Promise<{ code?: unknown; status? throw new Error('expected a refusal, the call resolved'); } +describe('the local placement binds its control socket to loopback itself', () => { + it('launchServer receives host 127.0.0.1 on the boot probe and on every render, never the library default', async () => { + const { launcher, launchServer } = stagedLauncher({}); + const driver = createChromiumDriver({ placement: 'local', target: '/usr/bin/chromium', consoleUrl: 'x' }, launcher); + await driver.probe(5_000); + await driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 5_000 }); + expect(launchServer.mock.calls.map(([options]) => options.host)).toEqual(['127.0.0.1', '127.0.0.1']); + }); +}); + describe('the driver redacts the session cookie from an engine error', () => { it('an engine error carrying the cookie value answers INTERNAL_ERROR / 500 with it replaced by [redacted]', async () => { const { launcher, closed } = stagedLauncher({ From 6232d379c7a55573c59288ae5021f04373779e78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 05:39:18 +0000 Subject: [PATCH 2/2] docs(changeset): state the measured behaviour on a host with no IPv4 loopback Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude --- .changeset/23968-print-local-loopback-host.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/23968-print-local-loopback-host.md b/.changeset/23968-print-local-loopback-host.md index 03f5f9ca066..f1583da56d8 100644 --- a/.changeset/23968-print-local-loopback-host.md +++ b/.changeset/23968-print-local-loopback-host.md @@ -6,6 +6,6 @@ Clause-②: no 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 -- **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 change it without a diff here. -- **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 whose `localhost` resolves to `::1` first, the socket now listens on `127.0.0.1` instead of `[::1]`, and the driver still connects to wherever it listens. On a host with no IPv4 loopback address, the local placement cannot open the socket. The boot probe reports the engine as unreachable, naming the address it could not bind, and a print is refused. The socket is never bound to another address instead. If that is your host, use the remote placement (`OS_PRINT_BROWSER_ENDPOINT`). +- **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. +- **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`). - **Unchanged:** the remote placement, the port (still random), every option and environment variable, and the exports.