chore: client certificates refactorings (#31822)

This commit is contained in:
Max Schmitt 2024-07-23 19:18:31 +02:00 committed by GitHub
parent f23d02a211
commit b9c4b6bff0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 101 additions and 86 deletions

View File

@ -35,7 +35,6 @@ class SocksProxyConnection {
target!: net.Socket; target!: net.Socket;
// In case of http, we just pipe data to the target socket and they are |undefined|. // In case of http, we just pipe data to the target socket and they are |undefined|.
internal: stream.Duplex | undefined; internal: stream.Duplex | undefined;
internalTLS: tls.TLSSocket | undefined;
constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) { constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) {
this.socksProxy = socksProxy; this.socksProxy = socksProxy;
@ -85,50 +84,51 @@ class SocksProxyConnection {
callback(); callback();
} }
}); });
const internalTLS = new tls.TLSSocket(this.internal, { const dummyServer = tls.createServer({
isServer: true,
key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')),
cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')), cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')),
}); });
this.internalTLS = internalTLS; dummyServer.emit('connection', this.internal);
internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); dummyServer.on('secureConnection', internalTLS => {
internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid }));
const tlsOptions: tls.ConnectionOptions = { const tlsOptions: tls.ConnectionOptions = {
socket: this.target, socket: this.target,
host: this.host, host: this.host,
port: this.port, port: this.port,
rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors,
...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}/`), ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}/`),
}; };
if (!net.isIP(this.host)) if (!net.isIP(this.host))
tlsOptions.servername = this.host; tlsOptions.servername = this.host;
if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest())
tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)];
const targetTLS = tls.connect(tlsOptions); const targetTLS = tls.connect(tlsOptions);
internalTLS.pipe(targetTLS); internalTLS.pipe(targetTLS);
targetTLS.pipe(internalTLS); targetTLS.pipe(internalTLS);
// Handle close and errors // Handle close and errors
const closeBothSockets = () => { const closeBothSockets = () => {
internalTLS.end(); internalTLS.end();
targetTLS.end(); targetTLS.end();
}; };
internalTLS.on('end', () => closeBothSockets()); internalTLS.on('end', () => closeBothSockets());
targetTLS.on('end', () => closeBothSockets()); targetTLS.on('end', () => closeBothSockets());
internalTLS.on('error', () => closeBothSockets()); internalTLS.on('error', () => closeBothSockets());
targetTLS.on('error', error => { targetTLS.on('error', error => {
const responseBody = 'Playwright client-certificate error: ' + error.message; const responseBody = 'Playwright client-certificate error: ' + error.message;
internalTLS.end([ internalTLS.end([
'HTTP/1.1 503 Internal Server Error', 'HTTP/1.1 503 Internal Server Error',
'Content-Type: text/html; charset=utf-8', 'Content-Type: text/html; charset=utf-8',
'Content-Length: ' + Buffer.byteLength(responseBody), 'Content-Length: ' + Buffer.byteLength(responseBody),
'\r\n', '\r\n',
responseBody, responseBody,
].join('\r\n')); ].join('\r\n'));
closeBothSockets(); closeBothSockets();
});
}); });
} }
} }

View File

@ -15,48 +15,55 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import http2 from 'http2';
import type http from 'http';
import { expect, playwrightTest as base } from '../config/browserTest'; import { expect, playwrightTest as base } from '../config/browserTest';
import type net from 'net'; import type net from 'net';
import type { BrowserContextOptions } from 'packages/playwright-test'; import type { BrowserContextOptions } from 'packages/playwright-test';
const { createHttpsServer } = require('../../packages/playwright-core/lib/utils'); const { createHttpsServer } = require('../../packages/playwright-core/lib/utils');
const test = base.extend<{ serverURL: string, serverURLRewrittenToLocalhost: string }>({ type TestOptions = {
serverURL: async ({ asset }, use) => { startCCServer(options?: {
const server = createHttpsServer({ http2?: boolean;
key: fs.readFileSync(asset('client-certificates/server/server_key.pem')), useFakeLocalhost?: boolean;
cert: fs.readFileSync(asset('client-certificates/server/server_cert.pem')), }): Promise<string>,
ca: [ };
fs.readFileSync(asset('client-certificates/server/server_cert.pem')),
], const test = base.extend<TestOptions>({
requestCert: true, startCCServer: async ({ asset, browserName }, use) => {
rejectUnauthorized: false,
}, (req, res) => {
const tlsSocket = req.socket as import('tls').TLSSocket;
// @ts-expect-error
expect(['localhost', 'local.playwright'].includes((tlsSocket).servername)).toBe(true);
const cert = tlsSocket.getPeerCertificate();
if ((req as any).client.authorized) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`);
} else if (cert.subject) {
res.writeHead(403, { 'Content-Type': 'text/html' });
res.end(`Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`);
} else {
res.writeHead(401, { 'Content-Type': 'text/html' });
res.end(`Sorry, but you need to provide a client certificate to continue.`);
}
});
process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('client-certificates/server/server_cert.pem'); process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('client-certificates/server/server_cert.pem');
await new Promise<void>(f => server.listen(0, 'localhost', () => f())); let server: http.Server | http2.Http2Server | undefined;
await use(`https://localhost:${(server.address() as net.AddressInfo).port}/`); await use(async options => {
server = (options?.http2 ? http2.createSecureServer : createHttpsServer)({
key: fs.readFileSync(asset('client-certificates/server/server_key.pem')),
cert: fs.readFileSync(asset('client-certificates/server/server_cert.pem')),
ca: [
fs.readFileSync(asset('client-certificates/server/server_cert.pem')),
],
requestCert: true,
rejectUnauthorized: false,
}, (req: (http2.Http2ServerRequest | http.IncomingMessage), res: http2.Http2ServerResponse | http.ServerResponse) => {
const tlsSocket = req.socket as import('tls').TLSSocket;
// @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62336
expect(['localhost', 'local.playwright'].includes((tlsSocket).servername)).toBe(true);
const cert = tlsSocket.getPeerCertificate();
if (tlsSocket.authorized) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`);
} else if (cert.subject) {
res.writeHead(403, { 'Content-Type': 'text/html' });
res.end(`Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`);
} else {
res.writeHead(401, { 'Content-Type': 'text/html' });
res.end(`Sorry, but you need to provide a client certificate to continue.`);
}
});
await new Promise<void>(f => server.listen(0, 'localhost', () => f()));
const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost';
return `https://${host}:${(server.address() as net.AddressInfo).port}/`;
});
await new Promise<void>(resolve => server.close(() => resolve())); await new Promise<void>(resolve => server.close(() => resolve()));
}, },
serverURLRewrittenToLocalhost: async ({ serverURL, browserName }, use) => {
const parsed = new URL(serverURL);
parsed.hostname = 'local.playwright';
const shouldRewriteToLocalhost = browserName === 'webkit' && process.platform === 'darwin';
await use(shouldRewriteToLocalhost ? parsed.toString() : serverURL);
}
}); });
test.use({ test.use({
@ -103,7 +110,8 @@ test.describe('fetch', () => {
await expect(playwright.request.newContext(contextOptions)).rejects.toThrow(expected); await expect(playwright.request.newContext(contextOptions)).rejects.toThrow(expected);
}); });
test('should fail with no client certificates provided', async ({ playwright, serverURL }) => { test('should fail with no client certificates provided', async ({ playwright, startCCServer }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext(); const request = await playwright.request.newContext();
const response = await request.get(serverURL); const response = await request.get(serverURL);
expect(response.status()).toBe(401); expect(response.status()).toBe(401);
@ -128,7 +136,8 @@ test.describe('fetch', () => {
await request.dispose(); await request.dispose();
}); });
test('should throw with untrusted client certs', async ({ playwright, serverURL, asset }) => { test('should throw with untrusted client certs', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
clientCertificates: [{ clientCertificates: [{
url: serverURL, url: serverURL,
@ -145,7 +154,8 @@ test.describe('fetch', () => {
await request.dispose(); await request.dispose();
}); });
test('pass with trusted client certificates', async ({ playwright, serverURL, asset }) => { test('pass with trusted client certificates', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
clientCertificates: [{ clientCertificates: [{
url: serverURL, url: serverURL,
@ -162,7 +172,8 @@ test.describe('fetch', () => {
await request.dispose(); await request.dispose();
}); });
test('should work in the browser with request interception', async ({ browser, playwright, serverURL, asset }) => { test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => {
const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
clientCertificates: [{ clientCertificates: [{
url: serverURL, url: serverURL,
@ -207,7 +218,8 @@ test.describe('browser', () => {
await page.close(); await page.close();
}); });
test('should fail with no client certificates', async ({ browser, serverURLRewrittenToLocalhost, asset }) => { test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
url: 'https://not-matching.com', url: 'https://not-matching.com',
@ -217,37 +229,39 @@ test.describe('browser', () => {
}], }],
}], }],
}); });
await page.goto(serverURLRewrittenToLocalhost); await page.goto(serverURL);
await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible(); await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible();
await page.close(); await page.close();
}); });
test('should fail with self-signed client certificates', async ({ browser, serverURLRewrittenToLocalhost, asset }) => { test('should fail with self-signed client certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
url: serverURLRewrittenToLocalhost, url: serverURL,
certs: [{ certs: [{
certPath: asset('client-certificates/client/self-signed/cert.pem'), certPath: asset('client-certificates/client/self-signed/cert.pem'),
keyPath: asset('client-certificates/client/self-signed/key.pem'), keyPath: asset('client-certificates/client/self-signed/key.pem'),
}], }],
}], }],
}); });
await page.goto(serverURLRewrittenToLocalhost); await page.goto(serverURL);
await expect(page.getByText('Sorry Bob, certificates from Bob are not welcome here')).toBeVisible(); await expect(page.getByText('Sorry Bob, certificates from Bob are not welcome here')).toBeVisible();
await page.close(); await page.close();
}); });
test('should pass with matching certificates', async ({ browser, serverURLRewrittenToLocalhost, asset }) => { test('should pass with matching certificates', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const page = await browser.newPage({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
url: serverURLRewrittenToLocalhost, url: serverURL,
certs: [{ certs: [{
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'),
}], }],
}], }],
}); });
await page.goto(serverURLRewrittenToLocalhost); await page.goto(serverURL);
await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible(); await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible();
await page.close(); await page.close();
}); });
@ -274,17 +288,18 @@ test.describe('browser', () => {
await expect(launchPersistent(contextOptions)).rejects.toThrow(expected); await expect(launchPersistent(contextOptions)).rejects.toThrow(expected);
}); });
test('should pass with matching certificates', async ({ launchPersistent, serverURLRewrittenToLocalhost, asset }) => { test('should pass with matching certificates', async ({ launchPersistent, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
const { page } = await launchPersistent({ const { page } = await launchPersistent({
clientCertificates: [{ clientCertificates: [{
url: serverURLRewrittenToLocalhost, url: serverURL,
certs: [{ certs: [{
certPath: asset('client-certificates/client/trusted/cert.pem'), certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'),
}], }],
}], }],
}); });
await page.goto(serverURLRewrittenToLocalhost); await page.goto(serverURL);
await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible(); await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible();
}); });
}); });