Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/23968-print-local-loopback-host.md
Original file line number Diff line number Diff line change
@@ -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 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.
27 changes: 26 additions & 1 deletion packages/services/service-print/src/chromium-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
28 changes: 22 additions & 6 deletions packages/services/service-print/src/chromium-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,14 +109,18 @@ interface ServerHandle {
kill(): Promise<void>;
}
interface ChromiumLauncher {
launchServer(options: { executablePath: string; headless: boolean; timeout: number }): Promise<ServerHandle>;
/** `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<ServerHandle>;
connect(wsEndpoint: string, options: { timeout: number }): Promise<BrowserHandle>;
connectOverCDP(endpoint: string, options: { timeout: number }): Promise<BrowserHandle>;
}

/** 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<unknown>, ms: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
Expand Down Expand Up @@ -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 });
Expand Down
15 changes: 13 additions & 2 deletions packages/services/service-print/src/chromium-driver.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,13 @@ function stagedLauncher(stage: Stage) {
closed.killed++;
}),
};
const launchServer = vi.fn(async (_options: Record<string, unknown>) => 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<unknown>): Promise<{ code?: unknown; status?: unknown; message: string }> {
Expand All @@ -86,6 +87,16 @@ async function refusalOf(p: Promise<unknown>): 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({
Expand Down
Loading