From cf31aa8b4c87cff318204c0e7cbe8e0475042828 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 5 Aug 2024 14:44:12 +0200 Subject: [PATCH] cherry-pick(#32008): chore(client-certificates): rewrite error for unsupported PFX errors --- packages/playwright-core/src/server/fetch.ts | 4 +- .../socksClientCertificatesInterceptor.ts | 19 +++++- .../src/utils/isomorphic/stringUtils.ts | 8 +++ packages/trace-viewer/src/snapshotRenderer.ts | 13 +--- .../client/trusted/cert-legacy.pfx | Bin 0 -> 4045 bytes tests/library/client-certificates.spec.ts | 60 ++++++++++++++++++ 6 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 tests/assets/client-certificates/client/trusted/cert-legacy.pfx diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 905f348c7c926..932381ce2a466 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -40,7 +40,7 @@ import { Tracing } from './trace/recorder/tracing'; import type * as types from './types'; import type { HeadersArray, ProxySettings } from './types'; import { kMaxCookieExpiresDateInSeconds } from './network'; -import { clientCertificatesToTLSOptions } from './socksClientCertificatesInterceptor'; +import { clientCertificatesToTLSOptions, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; type FetchRequestOptions = { userAgent: string; @@ -444,7 +444,7 @@ export abstract class APIRequestContext extends SdkObject { body.on('data', chunk => chunks.push(chunk)); body.on('end', notifyBodyFinished); }); - request.on('error', reject); + request.on('error', error => reject(rewriteOpenSSLErrorIfNeeded(error))); const disposeListener = () => { reject(new Error('Request context disposed.')); diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 13590579fed21..99b6c1f6ad3db 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -22,7 +22,7 @@ import fs from 'fs'; import tls from 'tls'; import stream from 'stream'; import { createSocket, createTLSSocket } from '../utils/happy-eyeballs'; -import { ManualPromise } from '../utils'; +import { escapeHTML, ManualPromise, rewriteErrorMessage } from '../utils'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy'; import type * as channels from '@protocol/channels'; @@ -150,8 +150,10 @@ class SocksProxyConnection { }; const handleError = (error: Error) => { - debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`); - const responseBody = 'Playwright client-certificate error: ' + error.message; + error = rewriteOpenSSLErrorIfNeeded(error); + debugLogger.log('client-certificates', `error when connecting to target: ${error.message.replaceAll('\n', ' ')}`); + const responseBody = escapeHTML('Playwright client-certificate error: ' + error.message) + .replaceAll('\n', '
'); if (internalTLS?.alpnProtocol === 'h2') { // This method is available only in Node.js 20+ if ('performServerHandshake' in http2) { @@ -297,3 +299,14 @@ export function clientCertificatesToTLSOptions( function rewriteToLocalhostIfNeeded(host: string): string { return host === 'local.playwright' ? 'localhost' : host; } + +export function rewriteOpenSSLErrorIfNeeded(error: Error): Error { + if (error.message !== 'unsupported') + return error; + return rewriteErrorMessage(error, [ + 'Unsupported TLS certificate.', + 'Most likely, the security algorithm of the given certificate was deprecated by OpenSSL.', + 'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider', + 'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223', + ].join('\n')); +} \ No newline at end of file diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index df213a10855fe..23c947cc49e82 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -132,3 +132,11 @@ export function escapeRegExp(s: string) { // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } + +const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; +export function escapeHTMLAttribute(s: string): string { + return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); +} +export function escapeHTML(s: string): string { + return s.replace(/[&<]/ug, char => (escaped as any)[char]); +} diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index 730cab4d1911f..0b458c0cb51ee 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils'; import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot'; function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot { @@ -57,7 +58,7 @@ export class SnapshotRenderer { // Old snapshotter was sending lower-case. if (parentTag === 'STYLE' || parentTag === 'style') return rewriteURLsInStyleSheetForCustomProtocol(n); - return escapeText(n); + return escapeHTML(n); } if (!(n as any)._string) { @@ -106,7 +107,7 @@ export class SnapshotRenderer { attrValue = 'link://' + value; else if (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute) attrValue = rewriteURLForCustomProtocol(value); - builder.push(' ', attrName, '="', escapeAttribute(attrValue), '"'); + builder.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"'); } builder.push('>'); for (const child of children) @@ -193,14 +194,6 @@ export class SnapshotRenderer { } const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); -const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; - -function escapeAttribute(s: string): string { - return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); -} -function escapeText(s: string): string { - return s.replace(/[&<]/ug, char => (escaped as any)[char]); -} function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] { if (!(snapshot as any)._nodes) { diff --git a/tests/assets/client-certificates/client/trusted/cert-legacy.pfx b/tests/assets/client-certificates/client/trusted/cert-legacy.pfx new file mode 100644 index 0000000000000000000000000000000000000000..9f06aa35c82f05be583a0cf8de295a68fdb9ccbf GIT binary patch literal 4045 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 v0%SM { await request.dispose(); }); + test('pass with trusted client certificates in pfx format', async ({ playwright, startCCServer, asset }) => { + const serverURL = await startCCServer(); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'secure' + }], + }); + const response = await request.get(serverURL); + expect(response.url()).toBe(serverURL); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); + await request.dispose(); + }); + + test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'this-password-is-incorrect' + }], + }); + await expect(request.get(serverURL)).rejects.toThrow('mac verify failure'); + await request.dispose(); + }); + + test('should fail with matching certificates in legacy pfx format', async ({ playwright, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'), + passphrase: 'secure' + }], + }); + await expect(request.get(serverURL)).rejects.toThrow('Unsupported TLS certificate'); + await request.dispose(); + }); + test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ @@ -272,6 +317,21 @@ test.describe('browser', () => { await page.close(); }); + test('should fail with matching certificates in legacy pfx format', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'), + passphrase: 'secure' + }], + }); + await page.goto(serverURL); + await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible(); + await page.close(); + }); + test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({