From 378a06054d2506ccb6e1e587a9009b6b05b94524 Mon Sep 17 00:00:00 2001 From: Sofia Cardita Date: Thu, 20 Aug 2026 11:53:42 +0100 Subject: [PATCH 1/4] feat(cloudflare): add session guardrails option to launch and acquire --- packages/playwright-cloudflare/index.d.ts | 38 +++++++++- .../src/cloudflare/guardrails.ts | 16 +++++ packages/playwright-cloudflare/src/index.ts | 33 +++++++-- .../src/browser-rendering/guardrails.spec.ts | 69 +++++++++++++++++++ .../tests/test-d/guardrails.test-d.ts | 51 ++++++++++++++ 5 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 packages/playwright-cloudflare/src/cloudflare/guardrails.ts create mode 100644 packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts create mode 100644 packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts diff --git a/packages/playwright-cloudflare/index.d.ts b/packages/playwright-cloudflare/index.d.ts index d74c157ae..f8a3a6486 100644 --- a/packages/playwright-cloudflare/index.d.ts +++ b/packages/playwright-cloudflare/index.d.ts @@ -31,6 +31,37 @@ export interface SessionlessBrowser extends Omit { sessionId(): undefined; } +/** + * Guardrails that restrict the outbound traffic of a browser session. + * + * @remarks + * Set when the session is acquired and latched for its lifetime: they cannot be + * changed or removed by later connections. An empty `allowedDomains` denies all + * outbound traffic, and an invalid policy fails closed rather than allowing + * unrestricted access. + * + * @public + */ +export interface SessionGuardrails { + /** + * Hostname patterns the browser may access, max 50. + * + * @remarks + * Each entry is a bare hostname (no scheme, port or path) and may contain a + * single `*` wildcard. Prefer `*.example.com` (subdomain wildcard) over + * `*example.com` (prefix wildcard), which also matches lookalikes such as + * `evilexample.com`. + */ + allowedDomains?: string[]; + /** + * Preset names or HTTPS URLs of newline-separated hostname lists, max 4. + * + * @remarks + * The available preset is `common-cdns`. + */ + allowedDomainSets?: string[]; +} + /** * @public */ @@ -104,6 +135,9 @@ export interface WorkersLaunchOptions { recording?: boolean; lab?: boolean; browser?: 'kitesurf'; // when set to 'kitesurf', no session is acquired and the connection is made directly to /v1/devtools/browser + // restricts the outbound traffic of the session being acquired, latched for + // its lifetime + guardrails?: SessionGuardrails; } /** @@ -120,7 +154,9 @@ type KeysByValueType = { export type BrowserBindingKey = KeysByValueType; -export function endpointURLString(binding: BrowserWorker | BrowserBindingKey, options?: WorkersLaunchOptions | WorkersConnectOptions): string; +// `guardrails` is excluded: they are sent in the acquire request body, so an endpoint +// URL has no way to carry them and accepting one here would silently drop it. +export function endpointURLString(binding: BrowserWorker | BrowserBindingKey, options?: Omit | WorkersConnectOptions): string; export function connect(endpoint: string | URL): Promise; export function connect(endpoint: BrowserWorker, sessionIdOrOptions: string | WorkersConnectOptions): Promise; diff --git a/packages/playwright-cloudflare/src/cloudflare/guardrails.ts b/packages/playwright-cloudflare/src/cloudflare/guardrails.ts new file mode 100644 index 000000000..33998b624 --- /dev/null +++ b/packages/playwright-cloudflare/src/cloudflare/guardrails.ts @@ -0,0 +1,16 @@ +import type { SessionGuardrails } from '../..'; + +// A websocket upgrade has no body, so a policy travels as this header instead. +export const GUARDRAILS_HEADER = 'cf-brapi-guardrails'; + +export function encodeGuardrailsHeader(policy: SessionGuardrails): string { + const bytes = new TextEncoder().encode(JSON.stringify(policy)); + // Built one byte at a time rather than `String.fromCharCode(...bytes)`: the spread passes one + // argument per byte, which blows the engine's argument limit once a policy outgrows the + // current caps. Core enforces those caps and may raise them. + let binary = ''; + for (const byte of bytes) + binary += String.fromCharCode(byte); + + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} diff --git a/packages/playwright-cloudflare/src/index.ts b/packages/playwright-cloudflare/src/index.ts index 51d67ff7d..941389f4e 100644 --- a/packages/playwright-cloudflare/src/index.ts +++ b/packages/playwright-cloudflare/src/index.ts @@ -6,11 +6,12 @@ import { setTimeOrigin, timeOrigin } from 'playwright-core/lib/utils/isomorphic/ import { transportZone, WebSocketTransport } from './cloudflare/webSocketTransport'; import { wrapClientApis } from './cloudflare/wrapClientApis'; import { unsupportedOperations } from './cloudflare/unsupportedOperations'; +import { encodeGuardrailsHeader, GUARDRAILS_HEADER } from './cloudflare/guardrails'; import * as packageJson from '../package.json'; import type { ProtocolRequest } from 'playwright-core/lib/server/transport'; import type { CRBrowser } from 'playwright-core/lib/server/chromium/crBrowser'; -import type { AcquireResponse, ActiveSession, Browser, BrowserBindingKey, BrowserEndpoint, BrowserWorker, ClosedSession, ConnectOverCDPOptions, HistoryResponse, LimitsResponse, SessionsResponse, WorkersLaunchOptions } from '..'; +import type { AcquireResponse, ActiveSession, Browser, BrowserBindingKey, BrowserEndpoint, BrowserWorker, ClosedSession, ConnectOverCDPOptions, HistoryResponse, LimitsResponse, SessionGuardrails, SessionsResponse, WorkersLaunchOptions } from '..'; import type { ChannelOwner } from 'playwright-core/lib/client/channelOwner'; function resetMonotonicTime() { @@ -45,20 +46,30 @@ const originalConnectOverCDP = playwright.chromium.connectOverCDP; : launch(wsUrl.toString()); }; -async function connectDevtools(endpoint: BrowserEndpoint, options: { sessionId?: string, persistent?: boolean, browser?: string }): Promise { +async function connectDevtools(endpoint: BrowserEndpoint, options: { sessionId?: string, persistent?: boolean, browser?: string, guardrails?: SessionGuardrails }): Promise { resetMonotonicTime(); const url = new URL(`${HTTP_FAKE_HOST}/v1/devtools/browser${options.sessionId ? `/${options.sessionId}` : ''}`); if (options.persistent) url.searchParams.set('persistent', 'true'); if (options.browser) url.searchParams.set('browser', options.browser); + // Only on the upgrade that acquires. Connecting to an existing session carries the policy + // it was acquired with, and core rejects a session-scoped one there because guardrails are + // latched at acquire time and cannot be changed afterwards. + const guardrails = options.sessionId ? undefined : options.guardrails; const response = await getBrowserBinding(endpoint).fetch(url, { headers: { 'Upgrade': 'websocket', 'cf-brapi-client': `@cloudflare/playwright@${packageJson.version}`, + ...(guardrails ? { [GUARDRAILS_HEADER]: encodeGuardrailsHeader(guardrails) } : {}), }, }); - const webSocket = response.webSocket!; + // A refused upgrade has no websocket to accept, so surface what core said instead of + // failing on a null dereference further down. + if (!response.webSocket) + throw new Error(`Unable to connect to browser: code: ${response.status}: message: ${await response.text()}`); + + const webSocket = response.webSocket; webSocket.accept(); return webSocket; } @@ -164,8 +175,20 @@ export async function acquire(endpoint: BrowserEndpoint, options?: WorkersLaunch if (options?.lab) searchParams.set("lab", options.lab.toString()); - const acquireUrl = `${HTTP_FAKE_HOST}/v1/acquire?${searchParams.toString()}`; - const res = await getBrowserBinding(endpoint).fetch(acquireUrl); + // POST /v1/devtools/browser rather than GET /v1/acquire: it takes the same query + // parameters and is the only acquire endpoint that accepts a guardrails policy. + const acquireUrl = `${HTTP_FAKE_HOST}/v1/devtools/browser?${searchParams.toString()}`; + const res = await getBrowserBinding(endpoint).fetch(acquireUrl, { + method: 'POST', + // Guardrails travel in the body here, unlike the websocket upgrades that have to + // use a header. + ...(options?.guardrails + ? { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ guardrails: options.guardrails }), + } + : {}), + }); const status = res.status; const text = await res.text(); if (status !== 200) { diff --git a/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts b/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts new file mode 100644 index 000000000..a9d6d00f6 --- /dev/null +++ b/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts @@ -0,0 +1,69 @@ +import { launch, acquire, sessions } from '@cloudflare/playwright'; + +import { test, expect } from '../server/workerFixtures'; + +test(`should allow navigation to an allowed domain and block everything else`, async ({ binding, server }) => { + const browser = await launch(binding, { guardrails: { allowedDomains: [server.HOSTNAME] } }); + try { + const page = await browser.newPage(); + + const allowed = await page.goto(server.EMPTY_PAGE); + expect(allowed!.status()).toBe(200); + + // Same zone, different hostname: the exact pattern above does not match it. + const blocked = await page.goto(`${server.CROSS_PROCESS_PREFIX}/empty.html`); + expect(blocked!.status()).toBe(403); + expect(blocked!.headers()['cf-mitigated']).toBe('guardrails'); + } finally { + await browser.close(); + } +}); + +test(`should match subdomains with a wildcard pattern`, async ({ binding, server }) => { + const [, parentDomain] = server.HOSTNAME.match(/^[^.]+\.(.+)$/)!; + const browser = await launch(binding, { guardrails: { allowedDomains: [`*.${parentDomain}`] } }); + try { + const page = await browser.newPage(); + const response = await page.goto(server.EMPTY_PAGE); + expect(response!.status()).toBe(200); + } finally { + await browser.close(); + } +}); + +test(`should deny all outbound traffic with an empty allowlist`, async ({ binding, server }) => { + const browser = await launch(binding, { guardrails: { allowedDomains: [] } }); + try { + const page = await browser.newPage(); + + const blocked = await page.goto(server.EMPTY_PAGE); + expect(blocked!.status()).toBe(403); + expect(blocked!.headers()['cf-mitigated']).toBe('guardrails'); + + // Denying every outbound request still leaves a usable page, which is the point + // of an empty allowlist. + await page.setContent(`
hello
`); + expect(await page.textContent('div')).toBe('hello'); + } finally { + await browser.close(); + } +}); + +test(`should acquire a session with guardrails`, async ({ binding }) => { + const { sessionId } = await acquire(binding, { guardrails: { allowedDomains: ['*.example.com'] } }); + expect(sessionId).toBeTruthy(); + expect((await sessions(binding)).map(s => s.sessionId)).toContain(sessionId); +}); + +test(`should reject an invalid policy at acquire time`, async ({ binding }) => { + // Wildcard-only would match any hostname, so it is not a valid pattern. + await expect(acquire(binding, { guardrails: { allowedDomains: ['*'] } })).rejects.toThrow(/code: 400/); +}); + +test(`should reject guardrails combined with browser=kitesurf`, async ({ binding }) => { + // kitesurf enforces its own allowlist under a different header, so core refuses the + // combination rather than hand back a session that quietly has no policy. The policy is + // still sent: core owns the rule, so it stays enforced if this client is out of date. + await expect(launch(binding, { browser: 'kitesurf', guardrails: { allowedDomains: ['*.example.com'] } })) + .rejects.toThrow(/code: 400.*browser=kitesurf/); +}); diff --git a/packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts b/packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts new file mode 100644 index 000000000..54262ea78 --- /dev/null +++ b/packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts @@ -0,0 +1,51 @@ +/** + * Type tests for session guardrails. + * + * Guardrails come in two scopes and the endpoint accepts only one of them. + * Only the session scope is supported here: `SessionGuardrails` are latched + * onto the session when it is acquired, so they belong to `launch()` and + * `acquire()`. The connection scope (`mode: 'readonly'`) is not exposed yet. + * + * See https://developers.cloudflare.com/browser-run/platform/guardrails/ + */ + +import { launch, acquire, endpointURLString } from '@cloudflare/playwright'; +import { expectAssignable, expectNotAssignable, expectType } from 'tsd'; + +import type { BrowserWorker, SessionGuardrails, WorkersLaunchOptions } from '@cloudflare/playwright'; + +declare const binding: BrowserWorker; + +const sessionPolicy: SessionGuardrails = { + allowedDomains: ['example.com', '*.example.com', 'api.*.example.com'], + allowedDomainSets: ['common-cdns', 'https://example.com/my-allowlist.txt'], +}; +expectType(sessionPolicy.allowedDomains); +expectType(sessionPolicy.allowedDomainSets); + +// Both properties are optional, and an empty allowlist is a valid policy +// meaning "deny all outbound traffic". +expectAssignable({}); +expectAssignable({ allowedDomains: [] }); + +// launch and acquire take the session scope. +expectAssignable({ guardrails: sessionPolicy }); +expectAssignable({ guardrails: { allowedDomains: ['*.example.com'] }, keep_alive: 30000 }); +await launch(binding, { guardrails: { allowedDomains: ['*.example.com'] } }); +await acquire(binding, { guardrails: { allowedDomainSets: ['common-cdns'] } }); + +// `mode` belongs to a connection, so it is not accepted at acquire time. +expectNotAssignable({ mode: 'readonly' }); +expectNotAssignable({ guardrails: { mode: 'readonly' } }); + +// Unknown guardrail properties are rejected. +expectNotAssignable({ guardrails: { allowedHosts: [] } }); + +// A policy is sent in the acquire request body, so an endpoint URL cannot carry one. +// @ts-expect-error +endpointURLString(binding, { guardrails: sessionPolicy }); + +// Guardrails stay optional: existing calls keep compiling. +await launch(binding); +await acquire(binding); +expectAssignable({ keep_alive: 30000 }); From c2c7cc110f98457b7ae0a5fbd4e294876593fc3b Mon Sep 17 00:00:00 2001 From: Sofia Cardita Date: Thu, 20 Aug 2026 11:53:43 +0100 Subject: [PATCH 2/4] test(cloudflare): add type tests for launch overloads --- .../tests/test-d/launch.test-d.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/playwright-cloudflare/tests/test-d/launch.test-d.ts diff --git a/packages/playwright-cloudflare/tests/test-d/launch.test-d.ts b/packages/playwright-cloudflare/tests/test-d/launch.test-d.ts new file mode 100644 index 000000000..aa6e0fc3e --- /dev/null +++ b/packages/playwright-cloudflare/tests/test-d/launch.test-d.ts @@ -0,0 +1,31 @@ +/** + * Type test for launch() overloads. + * + * `launch()` returns a SessionlessBrowser when `browser: 'kitesurf'` is passed, since + * no session is acquired in that case. Every other overload keeps `sessionId(): string`. + */ + +import { launch } from '@cloudflare/playwright'; +import { expectType } from 'tsd'; + +import type { Browser, BrowserWorker, SessionlessBrowser } from '@cloudflare/playwright'; + +declare const binding: BrowserWorker; + +// default launch — session backed, sessionId is always a string +expectType(await launch(binding)); +expectType((await launch(binding)).sessionId()); + +// launch with regular options — still session backed +expectType(await launch(binding, { keep_alive: 30000 })); +expectType((await launch(binding, { keep_alive: 30000 })).sessionId()); + +// kitesurf — no session, so no session id +expectType(await launch(binding, { browser: 'kitesurf' })); +expectType((await launch(binding, { browser: 'kitesurf' })).sessionId()); + +// kitesurf combined with other options still resolves to SessionlessBrowser +expectType(await launch(binding, { browser: 'kitesurf', keep_alive: 30000 })); + +// string endpoints keep working +expectType(await launch('http://fake.host/v1/devtools/browser?browser_binding=BROWSER')); From 0087d6e4dce4604d76110afa819eb7360513fb32 Mon Sep 17 00:00:00 2001 From: Sofia Cardita Date: Thu, 20 Aug 2026 12:15:02 +0100 Subject: [PATCH 3/4] feat: update version --- packages/playwright-cloudflare/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-cloudflare/package.json b/packages/playwright-cloudflare/package.json index 0d213f60b..db0295b8e 100644 --- a/packages/playwright-cloudflare/package.json +++ b/packages/playwright-cloudflare/package.json @@ -1,7 +1,7 @@ { "name": "@cloudflare/playwright", "description": "Playwright for Cloudflare Browser Run (formerly Browser Rendering)", - "version": "1.3.5-next", + "version": "1.3.6-next", "license": "Apache-2.0", "repository": { "type": "git", From 9f0c42a996850b7550875b71a297bc2947a5a613 Mon Sep 17 00:00:00 2001 From: Sofia Cardita Date: Thu, 20 Aug 2026 12:37:30 +0100 Subject: [PATCH 4/4] test(cloudflare): run the guardrails end-to-end test in the smoke suite --- .../tests/src/browser-rendering/guardrails.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts b/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts index a9d6d00f6..6ddb6e5cf 100644 --- a/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts +++ b/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts @@ -2,7 +2,7 @@ import { launch, acquire, sessions } from '@cloudflare/playwright'; import { test, expect } from '../server/workerFixtures'; -test(`should allow navigation to an allowed domain and block everything else`, async ({ binding, server }) => { +test(`should allow navigation to an allowed domain and block everything else @smoke`, async ({ binding, server }) => { const browser = await launch(binding, { guardrails: { allowedDomains: [server.HOSTNAME] } }); try { const page = await browser.newPage();