Merge 0223cc7191 into 31f4a05eb6
This commit is contained in:
commit
4780ec2bcd
|
|
@ -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.
|
||||
|
|
|
|||
12
packages/playwright-client/types/types.d.ts
vendored
12
packages/playwright-client/types/types.d.ts
vendored
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
12
packages/playwright-core/types/types.d.ts
vendored
12
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue