Add pattern can be used to select client certificates
This commit is contained in:
parent
2fb3e50631
commit
296ef60b22
|
|
@ -194,7 +194,7 @@ class SocksProxyConnection {
|
||||||
callback();
|
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 = {
|
const fixtures = {
|
||||||
__testHookLookup: (this._options as any).__testHookLookup
|
__testHookLookup: (this._options as any).__testHookLookup
|
||||||
};
|
};
|
||||||
|
|
@ -296,8 +296,9 @@ class SocksProxyConnection {
|
||||||
export class ClientCertificatesProxy {
|
export class ClientCertificatesProxy {
|
||||||
_socksProxy: SocksProxy;
|
_socksProxy: SocksProxy;
|
||||||
private _connections: Map<string, SocksProxyConnection> = new Map();
|
private _connections: Map<string, SocksProxyConnection> = new Map();
|
||||||
|
private _patterns: Pattern[] = [];
|
||||||
ignoreHTTPSErrors: boolean | undefined;
|
ignoreHTTPSErrors: boolean | undefined;
|
||||||
secureContextMap: Map<string, tls.SecureContext> = new Map();
|
private _secureContextMap: Map<string, tls.SecureContext> = new Map();
|
||||||
alpnCache: ALPNCache;
|
alpnCache: ALPNCache;
|
||||||
proxyAgentFromOptions: ReturnType<typeof createProxyAgent> | undefined;
|
proxyAgentFromOptions: ReturnType<typeof createProxyAgent> | undefined;
|
||||||
|
|
||||||
|
|
@ -336,7 +337,14 @@ export class ClientCertificatesProxy {
|
||||||
// Step 1. Group certificates by origin.
|
// Step 1. Group certificates by origin.
|
||||||
const origin2certs = new Map<string, types.BrowserContextOptions['clientCertificates']>();
|
const origin2certs = new Map<string, types.BrowserContextOptions['clientCertificates']>();
|
||||||
for (const cert of 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) || [];
|
const certs = origin2certs.get(origin) || [];
|
||||||
certs.push(cert);
|
certs.push(cert);
|
||||||
origin2certs.set(origin, certs);
|
origin2certs.set(origin, certs);
|
||||||
|
|
@ -345,7 +353,7 @@ export class ClientCertificatesProxy {
|
||||||
// Step 2. Create secure contexts for each origin.
|
// Step 2. Create secure contexts for each origin.
|
||||||
for (const [origin, certs] of origin2certs) {
|
for (const [origin, certs] of origin2certs) {
|
||||||
try {
|
try {
|
||||||
this.secureContextMap.set(origin, tls.createSecureContext(convertClientCertificatesToTLSOptions(certs)));
|
this._secureContextMap.set(origin, tls.createSecureContext(convertClientCertificatesToTLSOptions(certs)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error = rewriteOpenSSLErrorIfNeeded(error);
|
error = rewriteOpenSSLErrorIfNeeded(error);
|
||||||
throw rewriteErrorMessage(error, `Failed to load client certificate: ${error.message}`);
|
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() {
|
public async listen() {
|
||||||
const port = await this._socksProxy.listen(0, '127.0.0.1');
|
const port = await this._socksProxy.listen(0, '127.0.0.1');
|
||||||
return { server: `socks5://127.0.0.1:${port}` };
|
return { server: `socks5://127.0.0.1:${port}` };
|
||||||
|
|
@ -397,7 +412,7 @@ export function getMatchingTLSOptionsForOrigin(
|
||||||
origin: string
|
origin: string
|
||||||
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
|
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
|
||||||
const matchingCerts = clientCertificates?.filter(c =>
|
const matchingCerts = clientCertificates?.filter(c =>
|
||||||
normalizeOrigin(c.origin) === origin
|
Pattern.fromString(c.origin)?.matches(origin)
|
||||||
);
|
);
|
||||||
return convertClientCertificatesToTLSOptions(matchingCerts);
|
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',
|
'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223',
|
||||||
].join('\n'));
|
].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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import type net from 'net';
|
||||||
import type { BrowserContextOptions } from 'packages/playwright-test';
|
import type { BrowserContextOptions } from 'packages/playwright-test';
|
||||||
import { setupSocksForwardingServer } from '../config/proxy';
|
import { setupSocksForwardingServer } from '../config/proxy';
|
||||||
import { LookupAddress } from 'dns';
|
import { LookupAddress } from 'dns';
|
||||||
|
import { Pattern } from '../../packages/playwright-core/lib/server/socksClientCertificatesInterceptor';
|
||||||
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');
|
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');
|
||||||
|
|
||||||
type TestOptions = {
|
type TestOptions = {
|
||||||
|
|
@ -173,6 +174,32 @@ test.describe('fetch', () => {
|
||||||
await request.dispose();
|
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 }) => {
|
test('pass with trusted client certificates and when a http proxy is used', async ({ playwright, startCCServer, proxyServer, asset }) => {
|
||||||
const serverURL = await startCCServer();
|
const serverURL = await startCCServer();
|
||||||
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
|
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
|
||||||
|
|
@ -337,6 +364,33 @@ test.describe('browser', () => {
|
||||||
await page.close();
|
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 }) => {
|
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 serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac });
|
||||||
const page = await browser.newPage({
|
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!');
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue