From 296ef60b22d2ab87a1c4c7965aa290d43bbe9fc0 Mon Sep 17 00:00:00 2001 From: Michael Mac-Vicar Date: Thu, 27 Feb 2025 01:07:33 -0300 Subject: [PATCH] Add pattern can be used to select client certificates --- .../socksClientCertificatesInterceptor.ts | 193 +++++++++++++++++- tests/library/client-certificates.spec.ts | 129 ++++++++++++ 2 files changed, 317 insertions(+), 5 deletions(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index c30d5911d5..45cd5a1f6e 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -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 = new Map(); + private _patterns: Pattern[] = []; ignoreHTTPSErrors: boolean | undefined; - secureContextMap: Map = new Map(); + private _secureContextMap: Map = new Map(); alpnCache: ALPNCache; proxyAgentFromOptions: ReturnType | undefined; @@ -336,7 +337,14 @@ export class ClientCertificatesProxy { // Step 1. Group certificates by origin. const origin2certs = new Map(); 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 | 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); + } + +} diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 4ad672d606..d916c31668 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -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); + } + }); + }); });