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();
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue