Add pattern can be used to select client certificates

This commit is contained in:
Michael Mac-Vicar 2025-02-27 01:07:33 -03:00
parent 2fb3e50631
commit 296ef60b22
2 changed files with 317 additions and 5 deletions

View file

@ -194,7 +194,7 @@ class SocksProxyConnection {
callback();
}
});
const secureContext = this.socksProxy.secureContextMap.get(new URL(`https://${this.host}:${this.port}`).origin);
const secureContext = this.socksProxy.secureContextForOrigin(new URL(`https://${this.host}:${this.port}`).origin);
const fixtures = {
__testHookLookup: (this._options as any).__testHookLookup
};
@ -296,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;
@ -336,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);
@ -345,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}`);
@ -353,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}` };
@ -397,7 +412,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);
}
@ -416,3 +431,171 @@ 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'));
}
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

@ -25,6 +25,7 @@ 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 = {
@ -173,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 });
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 });
@ -337,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({
@ -892,4 +946,79 @@ 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',
],
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);
}
});
});
});