This commit is contained in:
Michael Mac-Vicar 2025-02-27 23:09:10 +01:00 committed by GitHub
commit 4780ec2bcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 528 additions and 51 deletions

View file

@ -549,7 +549,7 @@ Does not enforce fixed viewport, allows resizing window in the headed mode.
## context-option-clientCertificates
- `clientCertificates` <[Array]<[Object]>>
- `origin` <[string]> Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
- `origin` <[string]> Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
- `certPath` ?<[path]> Path to the file with the certificate in PEM format.
- `cert` ?<[Buffer]> Direct value of the certificate in PEM format.
- `keyPath` ?<[path]> Path to the file with the private key in PEM format.

View file

@ -9739,7 +9739,8 @@ export interface Browser {
*/
clientCertificates?: Array<{
/**
* Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
* Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes
* `https` protocol, a hostname and optionally a port.
*/
origin: string;
@ -14785,7 +14786,8 @@ export interface BrowserType<Unused = {}> {
*/
clientCertificates?: Array<{
/**
* Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
* Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes
* `https` protocol, a hostname and optionally a port.
*/
origin: string;
@ -17489,7 +17491,8 @@ export interface APIRequest {
*/
clientCertificates?: Array<{
/**
* Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
* Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes
* `https` protocol, a hostname and optionally a port.
*/
origin: string;
@ -21993,7 +21996,8 @@ export interface BrowserContextOptions {
*/
clientCertificates?: Array<{
/**
* Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
* Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes
* `https` protocol, a hostname and optionally a port.
*/
origin: string;

View file

@ -39,10 +39,15 @@ function loadDummyServerCertsIfNeeded() {
dummyServerTlsOptions = { key, cert };
}
type ALPNCacheOptions = {
socket?: stream.Duplex | undefined;
secureContext: tls.SecureContext | undefined;
};
class ALPNCache {
private _cache = new Map<string, ManualPromise<string>>();
get(host: string, port: number, success: (protocol: string) => void) {
get(host: string, port: number, options: ALPNCacheOptions, success: (protocol: string) => void) {
const cacheKey = `${host}:${port}`;
{
const result = this._cache.get(cacheKey);
@ -54,23 +59,56 @@ class ALPNCache {
const result = new ManualPromise<string>();
this._cache.set(cacheKey, result);
result.then(success);
createTLSSocket({
host,
port,
servername: net.isIP(host) ? undefined : host,
ALPNProtocols: ['h2', 'http/1.1'],
rejectUnauthorized: false,
}).then(socket => {
// The server may not respond with ALPN, in which case we default to http/1.1.
result.resolve(socket.alpnProtocol || 'http/1.1');
socket.end();
}).catch(error => {
debugLogger.log('client-certificates', `ALPN error: ${error.message}`);
result.resolve('http/1.1');
});
const fixtures = {
__testHookLookup: (options as any).__testHookLookup
};
if (!options.socket) {
createTLSSocket({
host,
port,
servername: net.isIP(host) ? undefined : host,
ALPNProtocols: ['h2', 'http/1.1'],
rejectUnauthorized: false,
secureContext: options.secureContext,
...fixtures,
}).then(socket => {
// The server may not respond with ALPN, in which case we default to http/1.1.
result.resolve(socket.alpnProtocol || 'http/1.1');
socket.end();
}).catch(error => {
debugLogger.log('client-certificates', `ALPN error: ${error.message}`);
result.resolve('http/1.1');
});
} else {
// a socket might be provided, for example, when using a proxy.
const socket = tls.connect({
socket: options.socket,
port: port,
host: host,
ALPNProtocols: ['h2', 'http/1.1'],
rejectUnauthorized: false,
secureContext: options.secureContext,
servername: net.isIP(host) ? undefined : host
});
socket.on('secureConnect', () => {
result.resolve(socket.alpnProtocol || 'http/1.1');
socket.end();
});
socket.on('error', error => {
result.resolve('http/1.1');
});
socket.on('timeout', () => {
result.resolve('http/1.1');
});
}
}
}
// Only used for fixtures
type SocksProxyConnectionOptions = {
};
class SocksProxyConnection {
private readonly socksProxy: ClientCertificatesProxy;
private readonly uid: string;
@ -84,12 +122,14 @@ class SocksProxyConnection {
private _targetCloseEventListener: () => void;
private _dummyServer: tls.Server | undefined;
private _closed = false;
private _options: SocksProxyConnectionOptions;
constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) {
constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number, options: SocksProxyConnectionOptions) {
this.socksProxy = socksProxy;
this.uid = uid;
this.host = host;
this.port = port;
this._options = options;
this._targetCloseEventListener = () => {
// Close the other end and cleanup TLS resources.
this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid });
@ -99,10 +139,14 @@ class SocksProxyConnection {
}
async connect() {
const fixtures = {
__testHookLookup: (this._options as any).__testHookLookup
};
if (this.socksProxy.proxyAgentFromOptions)
this.target = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false });
else
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);
this.target = await createSocket({ host: rewriteToLocalhostIfNeeded(this.host), port: this.port, ...fixtures });
this.target.once('close', this._targetCloseEventListener);
this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
@ -142,7 +186,7 @@ class SocksProxyConnection {
this.target.write(data);
}
private _attachTLSListeners() {
private async _attachTLSListeners() {
this.internal = new stream.Duplex({
read: () => {},
write: (data, encoding, callback) => {
@ -150,7 +194,20 @@ class SocksProxyConnection {
callback();
}
});
this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnProtocolChosenByServer => {
const secureContext = this.socksProxy.secureContextForOrigin(new URL(`https://${this.host}:${this.port}`).origin);
const fixtures = {
__testHookLookup: (this._options as any).__testHookLookup
};
const alpnCacheOptions: ALPNCacheOptions = {
secureContext,
...fixtures
};
if (this.socksProxy.proxyAgentFromOptions)
alpnCacheOptions.socket = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false });
this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnCacheOptions, alpnProtocolChosenByServer => {
alpnCacheOptions.socket?.destroy();
debugLogger.log('client-certificates', `Proxy->Target ${this.host}:${this.port} chooses ALPN ${alpnProtocolChosenByServer}`);
if (this._closed)
return;
@ -221,7 +278,7 @@ class SocksProxyConnection {
rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors,
ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'],
servername: !net.isIP(this.host) ? this.host : undefined,
secureContext: this.socksProxy.secureContextMap.get(new URL(`https://${this.host}:${this.port}`).origin),
secureContext: secureContext,
});
targetTLS.once('secureConnect', () => {
@ -239,8 +296,9 @@ class SocksProxyConnection {
export class ClientCertificatesProxy {
_socksProxy: SocksProxy;
private _connections: Map<string, SocksProxyConnection> = new Map();
private _patterns: Pattern[] = [];
ignoreHTTPSErrors: boolean | undefined;
secureContextMap: Map<string, tls.SecureContext> = new Map();
private _secureContextMap: Map<string, tls.SecureContext> = new Map();
alpnCache: ALPNCache;
proxyAgentFromOptions: ReturnType<typeof createProxyAgent> | undefined;
@ -256,7 +314,9 @@ export class ClientCertificatesProxy {
this._socksProxy.setPattern('*');
this._socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
try {
const connection = new SocksProxyConnection(this, payload.uid, payload.host, payload.port);
const connection = new SocksProxyConnection(this, payload.uid, payload.host, payload.port, {
__testHookLookup: (contextOptions as any).__testHookLookup
});
await connection.connect();
this._connections.set(payload.uid, connection);
} catch (error) {
@ -277,7 +337,14 @@ export class ClientCertificatesProxy {
// Step 1. Group certificates by origin.
const origin2certs = new Map<string, types.BrowserContextOptions['clientCertificates']>();
for (const cert of clientCertificates || []) {
const origin = normalizeOrigin(cert.origin);
const pattern = Pattern.fromString(cert.origin);
if (pattern === undefined) {
debugLogger.log('client-certificates', `Invalid client certificate pattern: ${cert.origin}`);
continue;
} else {
this._patterns.push(pattern);
}
const origin = pattern.normalizedOrigin;
const certs = origin2certs.get(origin) || [];
certs.push(cert);
origin2certs.set(origin, certs);
@ -286,7 +353,7 @@ export class ClientCertificatesProxy {
// Step 2. Create secure contexts for each origin.
for (const [origin, certs] of origin2certs) {
try {
this.secureContextMap.set(origin, tls.createSecureContext(convertClientCertificatesToTLSOptions(certs)));
this._secureContextMap.set(origin, tls.createSecureContext(convertClientCertificatesToTLSOptions(certs)));
} catch (error) {
error = rewriteOpenSSLErrorIfNeeded(error);
throw rewriteErrorMessage(error, `Failed to load client certificate: ${error.message}`);
@ -294,6 +361,13 @@ export class ClientCertificatesProxy {
}
}
public secureContextForOrigin(origin: string): tls.SecureContext | undefined {
const pattern = this._patterns.find(p => p.matches(origin));
if (!pattern)
return undefined;
return this._secureContextMap.get(pattern.normalizedOrigin);
}
public async listen() {
const port = await this._socksProxy.listen(0, '127.0.0.1');
return { server: `socks5://127.0.0.1:${port}` };
@ -304,14 +378,6 @@ export class ClientCertificatesProxy {
}
}
function normalizeOrigin(origin: string): string {
try {
return new URL(origin).origin;
} catch (error) {
return origin;
}
}
function convertClientCertificatesToTLSOptions(
clientCertificates: types.BrowserContextOptions['clientCertificates']
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
@ -338,7 +404,7 @@ export function getMatchingTLSOptionsForOrigin(
origin: string
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
const matchingCerts = clientCertificates?.filter(c =>
normalizeOrigin(c.origin) === origin
Pattern.fromString(c.origin)?.matches(origin)
);
return convertClientCertificatesToTLSOptions(matchingCerts);
}
@ -357,3 +423,181 @@ export function rewriteOpenSSLErrorIfNeeded(error: Error): Error {
'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223',
].join('\n'));
}
/*
Pattern is a pattern that matches a URL. Based on the Chromium
implementation, used in content policies:
https://source.chromium.org/chromium/chromium/src/+/main:components/content_settings/core/common/content_settings_pattern.h;l=248;drc=20799f4c32d950ce93d495f44eec648400f38a19
Example: "https://[*.].hello.com/path"
The only difference is that we don't support the precedence rules and
paths patterns are not implemented.
*/
export class Pattern {
private readonly _scheme: string;
private readonly _isSchemeWildcard: boolean;
private readonly _host: string;
private readonly _isDomainWildcard: boolean;
private readonly _isSubdomainWildcard: boolean;
private readonly _port: string;
private readonly _isPortWildcard: boolean;
private readonly _host_parts: string[];
private readonly _implicitPort: string;
private readonly _normalizedOrigin: string;
constructor(scheme: string, isSchemeWildcard: boolean, host: string, isDomainWildcard: boolean, isSubdomainWildcard: boolean, port: string, isPortWildcard: boolean) {
this._scheme = scheme;
this._isSchemeWildcard = isSchemeWildcard;
this._host = host;
this._isDomainWildcard = isDomainWildcard;
this._isSubdomainWildcard = isSubdomainWildcard;
this._port = port;
this._isPortWildcard = isPortWildcard;
this._host_parts = this._host.split('.').reverse();
this._implicitPort = this._scheme === 'https' ? '443' : (this._scheme === 'http' ? '80' : '');
this._normalizedOrigin = `${this._isSchemeWildcard ? '*' : this._scheme}://${this._isSubdomainWildcard ? '[*.]' : ''}${this._isDomainWildcard ? '*' : this._host}${this._isPortWildcard ? ':*' : this._port ? `:${this._port}` : ''}`;
}
get scheme() {
return this._scheme;
}
get host() {
return this._host;
}
get port() {
return this._port;
}
get isSchemeWildcard() {
return this._isSchemeWildcard;
}
get isDomainWildcard() {
return this._isDomainWildcard;
}
get isSubdomainWildcard() {
return this._isSubdomainWildcard;
}
get isPortWildcard() {
return this._isPortWildcard;
}
get normalizedOrigin() {
return this._normalizedOrigin;
}
matches(url: string): boolean {
const urlObj = new URL(url);
const urlScheme = urlObj.protocol.replace(':', '');
if (!this._isSchemeWildcard && this._scheme !== urlScheme)
return false;
let urlPort = urlObj.port;
if (urlPort === '')
urlPort = urlScheme === 'https' ? '443' : (urlScheme === 'http' ? '80' : '');
let patternPort = this._port;
if (patternPort === '')
patternPort = this._implicitPort;
if (!this._isPortWildcard && patternPort !== urlPort)
return false;
const urlHostParts = urlObj.hostname.split('.').reverse();
if (this._isDomainWildcard)
return true;
if (this._host_parts.length > urlHostParts.length)
return false;
for (let i = 0; i < this._host_parts.length; i++) {
if (this._host_parts[i] !== '*' && this._host_parts[i] !== urlHostParts[i])
return false;
}
if (this._host_parts.length < urlHostParts.length)
return this._isSubdomainWildcard;
return true;
}
static fromString(pattern: string, defaultScheme: string = 'https') {
let restPattern = pattern;
let scheme = '';
let host = '';
let port = '';
let isSchemeWildcard = false;
let isDomainWildcard = false;
let isSubdomainWildcard = false;
let isPortWildcard = false;
const schemeIndex = pattern.indexOf('://');
if (schemeIndex !== -1) {
scheme = restPattern.substring(0, schemeIndex);
restPattern = restPattern.substring(schemeIndex + 3);
} else {
scheme = defaultScheme;
}
// skip userinfo
const userInfoIndex = restPattern.indexOf('@');
if (userInfoIndex !== -1)
restPattern = restPattern.substring(schemeIndex + 1);
isSchemeWildcard = scheme === '*';
isSubdomainWildcard = restPattern.startsWith('[*.]');
if (isSubdomainWildcard)
restPattern = restPattern.substring(4);
// literal ipv6 address
if (restPattern.startsWith('[')) {
const closingBracketIndex = restPattern.indexOf(']');
if (closingBracketIndex === -1)
return undefined;
host = restPattern.substring(1, closingBracketIndex);
restPattern = restPattern.substring(closingBracketIndex + 1);
} else {
// ipv4 or domain
const slashIndex = restPattern.indexOf('/');
const portIndex = restPattern.indexOf(':');
host = restPattern;
if (slashIndex !== -1 && (portIndex === -1 || slashIndex < portIndex)) {
host = restPattern.substring(0, slashIndex);
restPattern = restPattern.substring(slashIndex);
} else if (portIndex !== -1) {
host = restPattern.substring(0, portIndex);
restPattern = restPattern.substring(portIndex);
} else {
restPattern = '';
}
}
if (host === '*')
isDomainWildcard = true;
const portIndex = restPattern.indexOf(':');
if (portIndex !== -1) {
if (restPattern.startsWith(':*')) {
isPortWildcard = true;
port = '*';
restPattern = restPattern.substring(2);
if (!restPattern.startsWith('/') || restPattern === '')
return undefined;
} else {
const slashIndex = restPattern.indexOf('/');
if (slashIndex !== -1) {
port = restPattern.substring(1, slashIndex);
restPattern = restPattern.substring(slashIndex);
} else {
port = restPattern.substring(1);
restPattern = '';
}
}
}
return new Pattern(scheme, isSchemeWildcard, host, isDomainWildcard, isSubdomainWildcard, port, isPortWildcard);
}
}

View file

@ -55,14 +55,14 @@ class HttpsHappyEyeballsAgent extends https.Agent {
export const httpsHappyEyeballsAgent = new HttpsHappyEyeballsAgent({ keepAlive: true });
export const httpHappyEyeballsAgent = new HttpHappyEyeballsAgent({ keepAlive: true });
export async function createSocket(host: string, port: number): Promise<net.Socket> {
export async function createSocket(options: { host: string, port: number }): Promise<net.Socket> {
return new Promise((resolve, reject) => {
if (net.isIP(host)) {
const socket = net.createConnection({ host, port });
if (net.isIP(options.host)) {
const socket = net.createConnection(options);
socket.on('connect', () => resolve(socket));
socket.on('error', error => reject(error));
} else {
createConnectionAsync({ host, port }, (err, socket) => {
createConnectionAsync(options, (err, socket) => {
if (err)
reject(err);
if (socket)

View file

@ -411,7 +411,7 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
private async _handleDirect(request: SocksSocketRequestedPayload) {
try {
const socket = await createSocket(request.host, request.port);
const socket = await createSocket({ host: request.host, port: request.port });
socket.on('data', data => this._connections.get(request.uid)?.sendData(data));
socket.on('error', error => {
this._connections.get(request.uid)?.error(error.message);
@ -540,7 +540,7 @@ export class SocksProxyHandler extends EventEmitter {
try {
if (this._redirectPortForTest)
port = this._redirectPortForTest;
const socket = await createSocket(host, port);
const socket = await createSocket({ host, port });
socket.on('data', data => {
const payload: SocksSocketDataPayload = { uid, data };
this.emit(SocksProxyHandler.Events.SocksData, payload);

View file

@ -9739,7 +9739,8 @@ export interface Browser {
*/
clientCertificates?: Array<{
/**
* Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
* Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes
* `https` protocol, a hostname and optionally a port.
*/
origin: string;
@ -14785,7 +14786,8 @@ export interface BrowserType<Unused = {}> {
*/
clientCertificates?: Array<{
/**
* Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
* Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes
* `https` protocol, a hostname and optionally a port.
*/
origin: string;
@ -17489,7 +17491,8 @@ export interface APIRequest {
*/
clientCertificates?: Array<{
/**
* Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
* Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes
* `https` protocol, a hostname and optionally a port.
*/
origin: string;
@ -21993,7 +21996,8 @@ export interface BrowserContextOptions {
*/
clientCertificates?: Array<{
/**
* Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port.
* Exact origin or chromium enterprise policy style URL pattern that the certificate is valid for. Origin includes
* `https` protocol, a hostname and optionally a port.
*/
origin: string;

View file

@ -129,16 +129,16 @@ export class TestProxy {
}
export async function setupSocksForwardingServer({
port, forwardPort, allowedTargetPort
port, forwardPort, allowedTargetPort, additionalAllowedHosts = []
}: {
port: number, forwardPort: number, allowedTargetPort: number
port: number, forwardPort: number, allowedTargetPort: number, additionalAllowedHosts?: string[]
}) {
const connectHosts = [];
const connections = new Map<string, net.Socket>();
const socksProxy = new SocksProxy();
socksProxy.setPattern('*');
socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) {
if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost', ...additionalAllowedHosts].includes(payload.host) || payload.port !== allowedTargetPort) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return;
}

View file

@ -24,6 +24,8 @@ import { expect, playwrightTest as base } from '../config/browserTest';
import type net from 'net';
import type { BrowserContextOptions } from 'packages/playwright-test';
import { setupSocksForwardingServer } from '../config/proxy';
import { LookupAddress } from 'dns';
import { Pattern } from '../../packages/playwright-core/lib/server/socksClientCertificatesInterceptor';
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');
type TestOptions = {
@ -172,6 +174,32 @@ test.describe('fetch', () => {
await request.dispose();
});
test('pass with trusted client certificates using pattern', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer();
const __testHookLookup = (hostname: string): LookupAddress[] => {
if (hostname === 'www.hello.local') {
return [
{ address: '127.0.0.1', family: 4 },
];
}
return [];
};
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: `https://[*.]hello.local:${new URL(serverURL).port}`,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
});
const response = await request.get(`https://www.hello.local:${new URL(serverURL).port}`, { __testHookLookup } as any);
expect(response.url()).toBe(`https://www.hello.local:${new URL(serverURL).port}/`);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
await request.dispose();
});
test('pass with trusted client certificates and when a http proxy is used', async ({ playwright, startCCServer, proxyServer, asset }) => {
const serverURL = await startCCServer();
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
@ -336,6 +364,33 @@ test.describe('browser', () => {
await page.close();
});
test('should pass with matching certificates when using pattern', async ({ browser, startCCServer, asset, browserName, isMac }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
const __testHookLookup = (hostname: string): LookupAddress[] => {
if (hostname === 'www.hello.local') {
return [
{ address: '127.0.0.1', family: 4 },
];
}
return [];
};
const context = await browser.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: `https://[*.]hello.local:${new URL(serverURL).port}`,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
...
{ __testHookLookup } as any
});
const page = await context.newPage();
const requestURL = `https://www.hello.local:${new URL(serverURL).port}`;
await page.goto(requestURL);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
});
test('should pass with matching certificates when passing as content', async ({ browser, startCCServer, asset, browserName, isMac }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
const page = await browser.newPage({
@ -371,6 +426,50 @@ test.describe('browser', () => {
await page.close();
});
test('should pass with matching certificates and when a http proxy is used on an otherwise unreachable server', async ({ browser, startCCServer, asset, browserName, proxyServer, isMac }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
const serverPort = parseInt(new URL(serverURL).port, 10);
const privateDomain = `private.playwright.test`;
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
// make private domain resolve to unreachable server 192.0.2.0
// any attempt to connect there will timeout
let interceptedHostnameLookup: string | undefined;
const __testHookLookup = (hostname: string): LookupAddress[] => {
if (hostname === privateDomain) {
interceptedHostnameLookup = hostname;
return [
{ address: '192.0.2.0', family: 4 },
];
}
return [];
};
const context = await browser.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin.replace('127.0.0.1', privateDomain),
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: `localhost:${proxyServer.PORT}` },
...
{ __testHookLookup } as any
});
const page = await context.newPage();
const requestURL = serverURL.replace('127.0.0.1', privateDomain);
expect(proxyServer.connectHosts).toEqual([]);
await page.goto(requestURL);
// only the proxy server should have tried to resolve the private domain
// and the test proxy server does not resolve domains
expect(interceptedHostnameLookup).toBe(undefined);
expect([...new Set(proxyServer.connectHosts)]).toEqual([`${privateDomain}:${serverPort}`]);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
});
test('should pass with matching certificates and when a socks proxy is used', async ({ browser, startCCServer, asset, browserName, isMac }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
const serverPort = parseInt(new URL(serverURL).port, 10);
@ -397,6 +496,55 @@ test.describe('browser', () => {
await closeProxyServer();
});
test('should pass with matching certificates and when a socks proxy is used on an otherwise unreachable server', async ({ browser, startCCServer, asset, browserName, isMac }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
const serverPort = parseInt(new URL(serverURL).port, 10);
const privateDomain = `private.playwright.test`;
const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({
port: test.info().workerIndex + 2048 + 2,
forwardPort: serverPort,
allowedTargetPort: serverPort,
additionalAllowedHosts: [privateDomain],
});
// make private domain resolve to unreachable server 192.0.2.0
// any attempt to connect will timeout
let interceptedHostnameLookup: string | undefined;
const __testHookLookup = (hostname: string): LookupAddress[] => {
if (hostname === privateDomain) {
interceptedHostnameLookup = hostname;
return [
{ address: '192.0.2.0', family: 4 },
];
}
return [];
};
const context = await browser.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin.replace('127.0.0.1', privateDomain),
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: proxyServerAddr },
...
{ __testHookLookup } as any
});
const page = await context.newPage();
expect(connectHosts).toEqual([]);
const requestURL = serverURL.replace('127.0.0.1', privateDomain);
await page.goto(requestURL);
// only the proxy server should have tried to resolve the private domain
// and the test proxy server does not resolve domains
expect(interceptedHostnameLookup).toBe(undefined);
expect(connectHosts).toEqual([`${privateDomain}:${serverPort}`]);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
await closeProxyServer();
});
test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => {
for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) {
await test.step(`TLS version: ${tlsVersion}`, async () => {
@ -798,4 +946,81 @@ test.describe('browser', () => {
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
});
});
test.describe('patterns', () => {
test('should match patterns correctly', async () => {
const testCases = [
{
pattern: 'https://*/path',
matches: [
'https://www.hello.com:443/path',
'https://www.sub.hello.com/path',
'https://10.0.0.1/path',
'https://[::1]/path',
],
nonMatches: [
'https://www.any.com:8443/path',
'http://www.any.com:443/path',
]
},
{
pattern: 'https://*.*/path',
matches: [
'https://hello.com/path',
],
nonMatches: [
'https://hello/path',
'http://www.hello.com/path',
]
},
{
pattern: 'https://www.hello.com:443/path',
matches: [
'https://www.hello.com/path',
],
nonMatches: [
'https://www.hello.com:8443/path',
'http://www.hello.com:443/path',
]
},
{
pattern: 'https://[*.]*.hello.com/path',
matches: [
'https://www.foo.bar.hello.com/path',
],
nonMatches: [
'https://hello.com/path',
'http://hello.com/path',
]
},
{
pattern: 'https://*/path',
matches: [
'https://www.hello.com/path',
],
nonMatches: [
'https://www.hello.com:8443/path',
'http://www.hello.com/path',
]
},
{
pattern: '*/path',
matches: [
'https://www.hello.com/path',
],
nonMatches: [
'http://www.hello.com/path',
]
},
];
for (const testCase of testCases) {
const pattern = Pattern.fromString(testCase.pattern);
expect(pattern).toBeTruthy();
for (const url of testCase.matches)
expect(pattern.matches(url), `Expected pattern "${testCase.pattern}" to match URL "${url}"`).toBe(true);
for (const url of testCase.nonMatches)
expect(pattern.matches(url), `Expected pattern "${testCase.pattern}" to NOT match URL "${url}"`).toBe(false);
}
});
});
});