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
38 changes: 37 additions & 1 deletion packages/playwright-cloudflare/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ export interface SessionlessBrowser extends Omit<Browser, 'sessionId'> {
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
*/
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -120,7 +154,9 @@ type KeysByValueType<T, ValueType> = {

export type BrowserBindingKey = KeysByValueType<typeof env, BrowserWorker>;

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<WorkersLaunchOptions, 'guardrails'> | WorkersConnectOptions): string;

export function connect(endpoint: string | URL): Promise<Browser>;
export function connect(endpoint: BrowserWorker, sessionIdOrOptions: string | WorkersConnectOptions): Promise<Browser>;
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-cloudflare/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions packages/playwright-cloudflare/src/cloudflare/guardrails.ts
Original file line number Diff line number Diff line change
@@ -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(/=+$/, '');
}
33 changes: 28 additions & 5 deletions packages/playwright-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<WebSocket> {
async function connectDevtools(endpoint: BrowserEndpoint, options: { sessionId?: string, persistent?: boolean, browser?: string, guardrails?: SessionGuardrails }): Promise<WebSocket> {
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;
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 @smoke`, 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(`<div>hello</div>`);
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/);
});
51 changes: 51 additions & 0 deletions packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<string[] | undefined>(sessionPolicy.allowedDomains);
expectType<string[] | undefined>(sessionPolicy.allowedDomainSets);

// Both properties are optional, and an empty allowlist is a valid policy
// meaning "deny all outbound traffic".
expectAssignable<SessionGuardrails>({});
expectAssignable<SessionGuardrails>({ allowedDomains: [] });

// launch and acquire take the session scope.
expectAssignable<WorkersLaunchOptions>({ guardrails: sessionPolicy });
expectAssignable<WorkersLaunchOptions>({ 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<SessionGuardrails>({ mode: 'readonly' });
expectNotAssignable<WorkersLaunchOptions>({ guardrails: { mode: 'readonly' } });

// Unknown guardrail properties are rejected.
expectNotAssignable<WorkersLaunchOptions>({ 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<WorkersLaunchOptions>({ keep_alive: 30000 });
31 changes: 31 additions & 0 deletions packages/playwright-cloudflare/tests/test-d/launch.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<Browser>(await launch(binding));
expectType<string>((await launch(binding)).sessionId());

// launch with regular options — still session backed
expectType<Browser>(await launch(binding, { keep_alive: 30000 }));
expectType<string>((await launch(binding, { keep_alive: 30000 })).sessionId());

// kitesurf — no session, so no session id
expectType<SessionlessBrowser>(await launch(binding, { browser: 'kitesurf' }));
expectType<undefined>((await launch(binding, { browser: 'kitesurf' })).sessionId());

// kitesurf combined with other options still resolves to SessionlessBrowser
expectType<SessionlessBrowser>(await launch(binding, { browser: 'kitesurf', keep_alive: 30000 }));

// string endpoints keep working
expectType<Browser>(await launch('http://fake.host/v1/devtools/browser?browser_binding=BROWSER'));
Loading