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
12 changes: 12 additions & 0 deletions .changeset/26664-print-under-default-csp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/service-print': patch
---

fix(service-print): a server print works under the console's default Content Security Policy

Clause-②: no
Metadata-protocol: none

Every server print (`POST /api/v1/print/records`, `printRecordToFile`) failed on a deployment that keeps the console's default CSP, answering "The print engine failed … page.waitForFunction: EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive". The Chromium driver waited for the print page with a string predicate, which playwright-core evaluates with `eval` in the page on every poll; the console's `script-src` carries no `'unsafe-eval'` unless `OS_PAGE_REACT_ENABLED` is on, so the second poll was refused.

The driver now hands the wait a function predicate. playwright-core evaluates a function's text once, inside the DevTools call that starts the wait, which the page's CSP does not govern, and each poll calls the function it produced. The wait means exactly what it meant: a root carrying `data-print-page` and an idle console (`window.__objectui.idle`), held for three polls in a row, polled every 100 ms within the render's one budget. The console CSP is unchanged.
34 changes: 32 additions & 2 deletions packages/services/service-print/src/chromium-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ import type { PrintSessionCookie } from './principal-session.js';
* host's CJK font, every code point mapped; with Chromium's fonts narrowed to a
* Latin-only fontconfig, none did.
*
* ## Under the console's CSP
*
* The console serves every page with a Content Security Policy whose
* `script-src` carries no `'unsafe-eval'` by default (it is added only for the
* `kind:'react'` page tier, `consoleContentSecurityPolicy` in
* `packages/cli/src/utils/console.ts`). The fixture serves the same print page
* under that directive too, so a ready wait that evaluates a string in the page
* fails here with the EvalError a default deployment answers.
*
* ## Which Chromium
*
* `OS_TEST_PRINT_CHROMIUM_PATH`, else Playwright's provisioned browser
Expand Down Expand Up @@ -82,12 +91,17 @@ const COOKIE: PrintSessionCookie = {
};
const CJK_TEXT = '发票客户名称上海示例有限公司合计金额';
const RECORD_PATH = '/_console/apps/showcase/invoice/record/inv_1';
/** The same record page, served under the console's default CSP. */
const CSP_RECORD_PATH = '/_console/apps/showcase/invoice/record/inv_csp';
const CSP_NONCE = 'print-fixture-nonce';
/** The console's default `script-src` (no `'unsafe-eval'`), with the rest of its policy's script-relevant directives. */
const CONSOLE_DEFAULT_CSP = `default-src 'self'; script-src 'self' 'nonce-${CSP_NONCE}' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'`;

function printPageHtml(): string {
function printPageHtml(nonce?: string): string {
return `<!doctype html><html><head><meta charset="utf-8">
<style>@media print { @page { size: A4 portrait; margin: 15mm; } nav { display: none; } }</style>
</head><body><nav>console chrome</nav><div id="root"></div>
<script>
<script${nonce ? ` nonce="${nonce}"` : ''}>
window.__objectui = { idle: false };
setTimeout(() => {
document.getElementById('root').innerHTML =
Expand All @@ -113,6 +127,11 @@ async function startFixture(): Promise<Fixture> {
cookieSeen.push(cookie);
res.setHeader('content-type', 'text/html; charset=utf-8');
const signedIn = cookie.split(/;\s*/).includes(`${COOKIE.name}=${COOKIE.value}`);
if (req.url === CSP_RECORD_PATH && signedIn) {
res.setHeader('content-security-policy', CONSOLE_DEFAULT_CSP);
res.end(printPageHtml(CSP_NONCE));
return;
}
res.end(req.url === RECORD_PATH && signedIn ? printPageHtml() : SIGNED_OUT_HTML);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
Expand Down Expand Up @@ -293,6 +312,17 @@ describe.skipIf(!CHROMIUM)('the Chromium engine on both placements', () => {
expect(missing, 'Chinese characters printed as empty boxes (no embedded glyph maps to them)').toEqual([]);
}, 60_000);

it('renders a page served under the console’s default CSP (no unsafe-eval): the ready wait evaluates no string in the page', async () => {
const pdf = await pick().driver.renderPdf({
url: `${fixture.origin}${CSP_RECORD_PATH}`,
cookie: COOKIE,
timeoutMs: 30_000,
});
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
const targets = toUnicodeTargets(pdf);
expect([...CJK_TEXT].filter((ch) => !targets.has(ch.codePointAt(0)!))).toEqual([]);
}, 60_000);

it('a page that never shows a print root is refused TIMEOUT / 504, not printed', async () => {
const r = await refusalOf(
pick().driver.renderPdf({
Expand Down
34 changes: 24 additions & 10 deletions packages/services/service-print/src/chromium-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ interface ContextHandle {
}
interface PageHandle {
goto(url: string, options: { waitUntil: 'load'; timeout: number }): Promise<unknown>;
waitForFunction(fn: string, arg: undefined, options: { timeout: number; polling: number }): Promise<unknown>;
waitForFunction(fn: (stablePolls: number) => boolean, arg: number, options: { timeout: number; polling: number }): Promise<unknown>;
evaluate(fn: string): Promise<unknown>;
pdf(options: { preferCSSPageSize: boolean; printBackground: boolean }): Promise<Buffer>;
}
Expand Down Expand Up @@ -141,15 +141,29 @@ interface OpenBrowser {
}

/**
* The ready predicate, evaluated in the page. A string, not a function, so the
* page sees exactly this text and nothing from this module's scope.
* The ready predicate, run in the page on every poll. A function, never a
* string, because of how playwright-core polls: it evaluates the predicate's
* text with `eval` in the page. A string is evaluated again on every poll, from
* a page timer, where the page's own CSP applies, so under the console's
* default `script-src` (no `'unsafe-eval'`) the second poll throws EvalError
* and every print fails. A function's text is evaluated once, inside the
* DevTools call that starts the wait, which a page's CSP does not govern; each
* poll then calls the function that evaluation produced.
*
* The page receives the function's text only, so it reads nothing from this
* module's scope: the poll count arrives as its argument.
*/
const PRINT_READY_PREDICATE = `(() => {
const ready = document.querySelector('[data-print-page]') !== null
&& window.__objectui !== undefined && window.__objectui.idle === true;
window.__objectstackPrintStable = ready ? (window.__objectstackPrintStable || 0) + 1 : 0;
return window.__objectstackPrintStable >= ${STABLE_POLLS};
})()`;
function printReady(stablePolls: number): boolean {
const page = globalThis as unknown as {
document: { querySelector(selector: string): unknown };
__objectui?: { idle?: unknown };
__objectstackPrintStable?: number;
};
const ready = page.document.querySelector('[data-print-page]') !== null
&& page.__objectui !== undefined && page.__objectui.idle === true;
page.__objectstackPrintStable = ready ? (page.__objectstackPrintStable || 0) + 1 : 0;
return page.__objectstackPrintStable >= stablePolls;
}

const FONTS_READY = `document.fonts.ready.then(() => true)`;

Expand Down Expand Up @@ -300,7 +314,7 @@ class PlaywrightChromiumDriver implements ChromiumDriver {
await held.context.addCookies([cookieFor(input.url, input.cookie)]);
const page = await held.context.newPage();
await page.goto(input.url, { waitUntil: 'load', timeout: remaining() });
await page.waitForFunction(PRINT_READY_PREDICATE, undefined, { timeout: remaining(), polling: POLL_INTERVAL_MS });
await page.waitForFunction(printReady, STABLE_POLLS, { timeout: remaining(), polling: POLL_INTERVAL_MS });
await page.evaluate(FONTS_READY);
return page.pdf({ preferCSSPageSize: true, printBackground: true });
})();
Expand Down
62 changes: 61 additions & 1 deletion packages/services/service-print/src/chromium-driver.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it, vi } from 'vitest';
import { createChromiumDriver, type ChromiumLauncher } from './chromium-driver.js';
import { createChromiumDriver, STABLE_POLLS, type ChromiumLauncher } from './chromium-driver.js';
import type { PrintSessionCookie } from './principal-session.js';

/**
Expand Down Expand Up @@ -202,3 +202,63 @@ describe('the cleanup after the deadline is bounded, and nothing opened late is
expect(browser.newContext).not.toHaveBeenCalled();
});
});

describe('the ready wait hands the page a function, never a string', () => {
/**
* playwright-core evaluates a string predicate with `eval` in the page on
* EVERY poll, from a timer the page's CSP governs: under the console's
* default `script-src` (no `'unsafe-eval'`) the second poll throws EvalError
* and every print fails. CI has no Chromium for the render tests, so this is
* the guard it runs.
*/
async function readyWait() {
const { launcher, page } = stagedLauncher({});
const driver = createChromiumDriver({ placement: 'remote', target: 'ws://127.0.0.1:9222/x', consoleUrl: 'x' }, launcher);
await driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 5_000 });
expect(page.waitForFunction).toHaveBeenCalledTimes(1);
const [predicate, arg, options] = page.waitForFunction.mock.calls[0] as unknown as [unknown, unknown, { timeout: number; polling: number }];
return { predicate, arg, options };
}

it('waitForFunction receives a function and the stable-poll count as its argument, polling every 100 ms within the budget', async () => {
const { predicate, arg, options } = await readyWait();
expect(typeof predicate).toBe('function');
expect(arg).toBe(STABLE_POLLS);
expect(options.polling).toBe(100);
expect(options.timeout).toBeGreaterThan(0);
expect(options.timeout).toBeLessThanOrEqual(5_000);
});

it('the function means what the wait meant: a data-print-page root AND an idle console, held for STABLE_POLLS polls in a row', async () => {
const { predicate, arg } = await readyWait();
const poll = predicate as (stablePolls: unknown) => boolean;
const g = globalThis as unknown as Record<string, unknown>;
const saved = { document: g.document, __objectui: g.__objectui, stable: g.__objectstackPrintStable };
let root: unknown = null;
g.document = { querySelector: (selector: string) => (selector === '[data-print-page]' ? root : null) };
try {
// No console yet, then a console that is idle before any print root exists.
expect(poll(arg)).toBe(false);
g.__objectui = { idle: true };
expect(poll(arg)).toBe(false);
// The root appears, but a request is in flight.
root = {};
g.__objectui = { idle: false };
expect(poll(arg)).toBe(false);
g.__objectui = { idle: true };
const run = Array.from({ length: STABLE_POLLS }, () => poll(arg));
expect(run).toEqual([...Array(STABLE_POLLS - 1).fill(false), true]);
// A not-ready poll resets the run.
g.__objectui = { idle: false };
expect(poll(arg)).toBe(false);
g.__objectui = { idle: true };
expect(Array.from({ length: STABLE_POLLS }, () => poll(arg))[STABLE_POLLS - 1]).toBe(true);
expect(poll(arg)).toBe(true);
} finally {
for (const [key, value] of [['document', saved.document], ['__objectui', saved.__objectui], ['__objectstackPrintStable', saved.stable]] as const) {
if (value === undefined) delete g[key];
else g[key] = value;
}
}
});
});
Loading