diff --git a/packages/isomorphic/base64.ts b/packages/isomorphic/base64.ts new file mode 100644 index 0000000000000..74af5669dd0d3 --- /dev/null +++ b/packages/isomorphic/base64.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function base64ByteLength(data: string): number { + if (!data) + return 0; + const padding = (data[data.length - 2] === '=') ? 2 : ((data[data.length - 1] === '=') ? 1 : 0); + return Math.max(0, Math.floor(data.length * 3 / 4) - padding); +} diff --git a/packages/isomorphic/index.ts b/packages/isomorphic/index.ts index 23884d42e3ec4..fd97cdfbcfffb 100644 --- a/packages/isomorphic/index.ts +++ b/packages/isomorphic/index.ts @@ -17,6 +17,7 @@ export * from './ariaSnapshot'; export * from './expectUtils'; export * from './assert'; +export * from './base64'; export * from './colors'; export * from './headers'; export * from './imageUtils'; diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 3bc23056da0dd..3720aee560635 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -54,6 +54,7 @@ export class CRNetworkManager { private _requestIdToRequestPausedEvent = new Map(); private _responseExtraInfoTracker = new ResponseExtraInfoTracker(); private _sessions = new Map(); + private _timestampBaselineForWebSocket = new Map(); constructor(page: Page | null, serviceWorker: CRServiceWorker | null) { this._page = page; @@ -76,11 +77,11 @@ export class CRNetworkManager { if (this._page) { sessionInfo.eventListeners.push(...[ eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page!.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers, '\n'), e.wallTime * 1000, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', event => this._onWebSocketWillSendHandshakeRequest(event)), eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, '\n'))), - eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), - eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page!.frameManager.webSocketClosed(e.requestId)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, this._timestampToWallTimeMsForWebSocket(e.requestId, e.timestamp))), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, this._timestampToWallTimeMsForWebSocket(e.requestId, e.timestamp))), + eventsHelper.addEventListener(session, 'Network.webSocketClosed', event => this._onWebSocketClosed(event)), eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page!.frameManager.webSocketError(e.requestId, e.errorMessage)), ]); } @@ -546,6 +547,21 @@ export class CRNetworkManager { (this._page?.frameManager || this._serviceWorker)!.requestFailed(request.request, !!event.canceled); } + _onWebSocketWillSendHandshakeRequest(event: Protocol.Network.webSocketWillSendHandshakeRequestPayload) { + const wallTimeMs = event.wallTime * 1000; + this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp); + this._page!.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers, '\n'), wallTimeMs); + } + + _onWebSocketClosed(event: Protocol.Network.webSocketClosedPayload) { + this._timestampBaselineForWebSocket.delete(event.requestId); + this._page!.frameManager.webSocketClosed(event.requestId); + } + + _timestampToWallTimeMsForWebSocket(requestId: string, timestamp: number): number { + return this._timestampBaselineForWebSocket.get(requestId)! + timestamp; + } + private _maybeUpdateRequestSession(sessionInfo: SessionInfo, request: InterceptableRequest) { // OOPIF has a main request that starts in the parent session but finishes in the child session. // We check for the main request by matching loaderId and requestId, and if it now belongs to diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index d1eeedeb9398b..573b877cbc511 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -403,12 +403,12 @@ export class FrameManager { this._webSockets.set(requestId, ws); } - onWebSocketRequest(requestId: string, headers: types.HeadersArray, wallTime?: number, timestamp?: number) { + onWebSocketRequest(requestId: string, headers: types.HeadersArray, wallTimeMs?: number) { const ws = this._webSockets.get(requestId); if (!ws) return; - ws.setRequestTiming(wallTime, timestamp); + ws.setWallTimeMs(wallTimeMs); if (ws.markAsNotified()) this._page.emit(Page.Events.WebSocket, ws); @@ -426,16 +426,16 @@ export class FrameManager { ws.error(`${statusText}: ${status}`); } - onWebSocketFrameSent(requestId: string, opcode: number, data: string, timestamp: number) { + onWebSocketFrameSent(requestId: string, opcode: number, data: string, wallTimeMs: number) { const ws = this._webSockets.get(requestId); if (ws) - ws.frameSent(opcode, data, timestamp); + ws.frameSent(opcode, data, wallTimeMs); } - webSocketFrameReceived(requestId: string, opcode: number, data: string, timestamp: number) { + webSocketFrameReceived(requestId: string, opcode: number, data: string, wallTimeMs: number) { const ws = this._webSockets.get(requestId); if (ws) - ws.frameReceived(opcode, data, timestamp); + ws.frameReceived(opcode, data, wallTimeMs); } webSocketClosed(requestId: string) { diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 458d98f90e01a..9205c6176178e 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -15,6 +15,7 @@ */ import mime from 'mime'; +import { base64ByteLength } from '@isomorphic/base64'; import { ManualPromise } from '@isomorphic/manualPromise'; import { eventsHelper } from '@utils/eventsHelper'; import { assert } from '@isomorphic/assert'; @@ -440,25 +441,68 @@ export class HarTracer { if (!url) return; + const method = 'GET'; const pageEntry = this._createPageEntryIfNeeded(page); - const harEntry = createHarEntry(pageEntry?.id, 'GET', url, page.mainFrame().guid, this._options, webSocket.wallTimeMs()); + const harEntry = createHarEntry(pageEntry?.id, method, url, page.mainFrame().guid, this._options, webSocket.wallTimeMs()); harEntry._resourceType = 'websocket'; harEntry._webSocketMessages = []; + let oldestWallTimeMs = Infinity; + let newestWallTimeMs = -Infinity; + const updateTime = (wallTimeMs: number) => { + if (this._options.omitTiming) + return; + + if (wallTimeMs >= oldestWallTimeMs && wallTimeMs <= newestWallTimeMs) + return; + + if (wallTimeMs < oldestWallTimeMs) + oldestWallTimeMs = wallTimeMs; + if (wallTimeMs > newestWallTimeMs) + newestWallTimeMs = wallTimeMs; + if (oldestWallTimeMs === newestWallTimeMs) + return; + + harEntry.time = newestWallTimeMs - oldestWallTimeMs; + }; + const eventListeners = [ eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Request, ({ headers }: { headers: HeadersArray }) => { this._recordRequestHeadersAndCookies(harEntry, headers); + if (!this._options.omitSizes) + harEntry.request.headersSize = network.requestHeadersSize(headers, webSocket.url(), method); }), eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Response, ({ status, statusText, headers }: { status: number, statusText: string, headers: HeadersArray }) => { harEntry.response.status = status; harEntry.response.statusText = statusText; this._recordResponseHeaders(harEntry, headers); + if (!this._options.omitSizes) { + harEntry.response.headersSize = network.responseHeadersSize(headers, statusText); + harEntry.response._transferSize = Math.max(0, harEntry.response._transferSize!) + harEntry.response.headersSize; + } }), - eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameSent, ({ opcode, data, timestamp }: { opcode: number, data: string, timestamp: number }) => { - harEntry._webSocketMessages!.push({ type: 'send', time: timestamp, opcode, data }); + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameSent, ({ opcode, data, wallTimeMs }: { opcode: number, data: string, wallTimeMs: number }) => { + harEntry._webSocketMessages!.push({ type: 'send', time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }); + updateTime(wallTimeMs); }), - eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameReceived, ({ opcode, data, timestamp }: { opcode: number, data: string, timestamp: number }) => { - harEntry._webSocketMessages!.push({ type: 'receive', time: timestamp, opcode, data }); + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameReceived, ({ opcode, data, wallTimeMs }: { opcode: number, data: string, wallTimeMs: number }) => { + harEntry._webSocketMessages!.push({ type: 'receive', time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data }); + updateTime(wallTimeMs); + if (!this._options.omitSizes) { + const length = (opcode === 1) ? Buffer.byteLength(data, 'utf8') : base64ByteLength(data); + + // According to : + // - there are always 16 bits at the beginning of every frame: FIN RSV1 RSV2 RSV3 opcode(4) mask length(7) + // - there are always 4 bytes for the masking key (see ) + // - there may be an additional 16 or 64 bits for payload length if it's too long to fit in the above 7 bits (or if it also can't fit in 16 bits) + let headerSize = 6; + if (length > 2 ** 16) + headerSize += 8; + else if (length > 125) + headerSize += 2; + + harEntry.response._transferSize = Math.max(0, harEntry.response._transferSize!) + headerSize + length; + } }), eventsHelper.addEventListener(webSocket, network.WebSocket.Events.SocketError, (errorMessage: string) => { harEntry.response._failureText = errorMessage; diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index c156099cdaa5a..e0ea2fee3e0f4 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -336,14 +336,7 @@ export class Request extends SdkObject { } async _requestHeadersSize(): Promise { - let headersSize = 4; // 4 = 2 spaces + 2 line breaks (GET /path \r\n) - headersSize += this.method().length; - headersSize += (new URL(this.url())).pathname.length; - headersSize += 8; // httpVersion - const headers = await this._rawRequestHeaders(); - for (const header of headers) - headersSize += header.name.length + header.value.length + 4; // 4 = ': ' + '\r\n' - return headersSize; + return requestHeadersSize(await this._rawRequestHeaders(), this.url(), this.method()); } } @@ -689,15 +682,7 @@ export class Response extends SdkObject { return availableSize; // Fallback to calculating it manually. - let headersSize = 4; // 4 = 2 spaces + 2 line breaks (HTTP/1.1 200 Ok\r\n) - headersSize += 8; // httpVersion; - headersSize += 3; // statusCode; - headersSize += this.statusText().length; - const headers = await this._rawResponseHeadersPromise; - for (const header of headers) - headersSize += header.name.length + header.value.length + 4; // 4 = ': ' + '\r\n' - headersSize += 2; // '\r\n' - return headersSize; + return responseHeadersSize(await this._rawResponseHeadersPromise, this.statusText()); } private async _sizes(): Promise { @@ -731,8 +716,7 @@ export class Response extends SdkObject { export class WebSocket extends SdkObject { private _url: string; private _notified = false; - private _requestWallTimeMs: number | undefined; - private _requestTimestamp: number | undefined; + private _wallTimeMs: number | undefined; private _status: number | undefined; private _statusText: string | undefined; private _requestHeaders: HeadersArray | undefined; @@ -749,7 +733,7 @@ export class WebSocket extends SdkObject { constructor(parent: SdkObject, url: string) { super(parent, 'ws'); - this._url = url; + this._url = stripFragmentFromUrl(url); } markAsNotified() { @@ -767,12 +751,11 @@ export class WebSocket extends SdkObject { } wallTimeMs(): number | undefined { - return this._requestWallTimeMs; + return this._wallTimeMs; } - setRequestTiming(wallTimeMs: number | undefined, timestamp: number | undefined) { - this._requestWallTimeMs = wallTimeMs; - this._requestTimestamp = timestamp; + setWallTimeMs(wallTimeMs: number | undefined) { + this._wallTimeMs = wallTimeMs; } requestSent(headers: HeadersArray) { @@ -783,29 +766,12 @@ export class WebSocket extends SdkObject { this.emit(WebSocket.Events.Response, { status, statusText, headers }); } - private _toWallTime(timestamp: number): number { - // The timestamp of each frame is relative to the timestamp (and walltime) of the initial request in Chromium and WebKit. - if (this._requestWallTimeMs !== undefined && this._requestTimestamp !== undefined) - return this._requestWallTimeMs + (timestamp - this._requestTimestamp); - - // The timestamp is already a walltime in Firefox. - return timestamp; + frameSent(opcode: number, data: string, wallTimeMs: number) { + this.emit(WebSocket.Events.FrameSent, { opcode, data, wallTimeMs }); } - frameSent(opcode: number, data: string, timestamp: number) { - this.emit(WebSocket.Events.FrameSent, { - opcode, - data, - timestamp: this._toWallTime(timestamp), - }); - } - - frameReceived(opcode: number, data: string, timestamp: number) { - this.emit(WebSocket.Events.FrameReceived, { - opcode, - data, - timestamp: this._toWallTime(timestamp), - }); + frameReceived(opcode: number, data: string, wallTimeMs: number) { + this.emit(WebSocket.Events.FrameReceived, { opcode, data, wallTimeMs }); } error(errorMessage: string) { @@ -915,3 +881,29 @@ export function mergeHeaders(headers: (HeadersArray | undefined | null)[]): Head result.push({ name: lowerCaseToOriginalCase.get(lower)!, value }); return result; } + +function headersSize(headers: HeadersArray): number { + let result = 0; + for (const header of headers) + result += header.name.length + header.value.length + 4; // 4 = ': ' + '\r\n' + return result; +} + +export function requestHeadersSize(headers: HeadersArray, url: string, method: string): number { + let result = 4; // 4 = 2 spaces + 2 line breaks (GET /path \r\n) + result += method.length; + result += (new URL(url)).pathname.length; + result += 8; // httpVersion + result += headersSize(headers); + return result; +} + +export function responseHeadersSize(headers: HeadersArray, statusText: string): number { + let result = 4; // 4 = 2 spaces + 2 line breaks (HTTP/1.1 200 Ok\r\n) + result += 8; // httpVersion; + result += 3; // statusCode; + result += statusText.length; + result += headersSize(headers); + result += 2; // '\r\n' + return result; +} diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 3a5311aa84ab0..3174827e28bd4 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -160,9 +160,11 @@ class SocksProxyConnection { // the protocol on the first package and attach appropriate listeners. if (!this._firstPackageReceived) { this._firstPackageReceived = true; - // 0x16 is SSLv3/TLS "handshake" content type: https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_record - if (data[0] === 0x16) - this._establishTlsTunnel(this._browserEncrypted, data); + // 0x16 is the TLS "handshake" content type. Only intercept it when the origin has a client + // certificate; otherwise pass the connection through so the browser talks TLS to the server directly. + const secureContext = data[0] === 0x16 ? this.socksProxy.secureContextMap.get(normalizeOrigin(`https://${this.host}:${this.port}`)) : undefined; + if (secureContext) + this._establishTlsTunnel(this._browserEncrypted, data, secureContext); else this._establishPlaintextTunnel(this._browserEncrypted); } @@ -176,14 +178,11 @@ class SocksProxyConnection { this._serverEncrypted.pipe(browserEncrypted); } - private _establishTlsTunnel(browserEncrypted: stream.Duplex, clientHello: Buffer) { + private _establishTlsTunnel(browserEncrypted: stream.Duplex, clientHello: Buffer, secureContext: tls.SecureContext) { const browserALPNProtocols = parseALPNFromClientHello(clientHello) || ['http/1.1']; debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} offers ALPN ${browserALPNProtocols}`); - const secureContext = this.socksProxy.secureContextMap.get(normalizeOrigin(`https://${this.host}:${this.port}`)); - // Without a matching client certificate the proxy is a transparent pass-through, so let the - // browser validate the server cert instead of failing here on e.g. self-signed certs. - const rejectUnauthorized = !!secureContext && !this.socksProxy.ignoreHTTPSErrors; + const rejectUnauthorized = !this.socksProxy.ignoreHTTPSErrors; const serverDecrypted = tls.connect({ socket: this._serverEncrypted, diff --git a/packages/playwright-core/src/server/webkit/webview/wvPage.ts b/packages/playwright-core/src/server/webkit/webview/wvPage.ts index 5e41bf4a2cf7a..93f8f8a611e10 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvPage.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvPage.ts @@ -71,6 +71,7 @@ export class WVPage implements PageDelegate { private _initializedPromise = new ManualPromise(); private _lastConsoleMessage: { derivedType: string, text: string, handles: JSHandle[]; count: number, location: types.ConsoleMessageLocation; } | null = null; private readonly _requestIdToResponseReceivedPayloadEvent = new Map(); + private _timestampBaselineForWebSocket = new Map(); private readonly _dialogEndpoint: string | undefined; @@ -327,11 +328,11 @@ export class WVPage implements PageDelegate { eventsHelper.addEventListener(session, 'Network.loadingFinished', e => this._onLoadingFinished(e)), eventsHelper.addEventListener(session, 'Network.loadingFailed', e => this._onLoadingFailed(session, e)), eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers), e.walltime * 1000, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', event => this._onWebSocketWillSendHandshakeRequest(event)), eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, ','))), - eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), - eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page.frameManager.webSocketClosed(e.requestId)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, this._timestampToWallTimeMsForWebSocket(e.requestId, e.timestamp))), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, this._timestampToWallTimeMsForWebSocket(e.requestId, e.timestamp))), + eventsHelper.addEventListener(session, 'Network.webSocketClosed', event => this._onWebSocketClosed(event)), eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page.frameManager.webSocketError(e.requestId, e.errorMessage)), ]; } @@ -951,6 +952,21 @@ export class WVPage implements PageDelegate { this._page.frameManager.requestFailed(request.request, event.errorText.includes('cancelled')); } + _onWebSocketWillSendHandshakeRequest(event: Protocol.Network.webSocketWillSendHandshakeRequestPayload) { + const wallTimeMs = event.walltime * 1000; + this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp); + this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs); + } + + _onWebSocketClosed(event: Protocol.Network.webSocketClosedPayload) { + this._timestampBaselineForWebSocket.delete(event.requestId); + this._page.frameManager.webSocketClosed(event.requestId); + } + + _timestampToWallTimeMsForWebSocket(requestId: string, timestamp: number): number { + return this._timestampBaselineForWebSocket.get(requestId)! + timestamp; + } + shouldToggleStyleSheetToSyncAnimations(): boolean { return true; } diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 845ff30ea9616..6a2540390e809 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -73,8 +73,8 @@ export class WKPage implements PageDelegate { private _firstNonInitialNavigationCommittedFulfill = () => {}; _firstNonInitialNavigationCommittedReject = (e: Error) => {}; private _lastConsoleMessage: { derivedType: string, text: string, handles: JSHandle[]; count: number, location: types.ConsoleMessageLocation; } | null = null; - private readonly _requestIdToResponseReceivedPayloadEvent = new Map(); + private _timestampBaselineForWebSocket = new Map(); // Holds window features for the next popup being opened via window.open, // until the popup page proxy arrives. private _nextWindowOpenPopupFeatures?: string[]; @@ -397,11 +397,11 @@ export class WKPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Network.loadingFinished', e => this._onLoadingFinished(e)), eventsHelper.addEventListener(this._session, 'Network.loadingFailed', e => this._onLoadingFailed(this._session, e)), eventsHelper.addEventListener(this._session, 'Network.webSocketCreated', e => this._page.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(this._session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers), e.walltime * 1000, e.timestamp)), + eventsHelper.addEventListener(this._session, 'Network.webSocketWillSendHandshakeRequest', event => this._onWebSocketWillSendHandshakeRequest(event)), eventsHelper.addEventListener(this._session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, ',', wkSetCookieSeparator))), - eventsHelper.addEventListener(this._session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), - eventsHelper.addEventListener(this._session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), - eventsHelper.addEventListener(this._session, 'Network.webSocketClosed', e => this._page.frameManager.webSocketClosed(e.requestId)), + eventsHelper.addEventListener(this._session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, this._timestampToWallTimeMsForWebSocket(e.requestId, e.timestamp))), + eventsHelper.addEventListener(this._session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, this._timestampToWallTimeMsForWebSocket(e.requestId, e.timestamp))), + eventsHelper.addEventListener(this._session, 'Network.webSocketClosed', event => this._onWebSocketClosed(event)), eventsHelper.addEventListener(this._session, 'Network.webSocketFrameError', e => this._page.frameManager.webSocketError(e.requestId, e.errorMessage)), ]; } @@ -1212,6 +1212,21 @@ export class WKPage implements PageDelegate { this._page.frameManager.requestFailed(request.request, event.errorText.includes('cancelled')); } + _onWebSocketWillSendHandshakeRequest(event: Protocol.Network.webSocketWillSendHandshakeRequestPayload) { + const wallTimeMs = event.walltime * 1000; + this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp); + this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs); + } + + _onWebSocketClosed(event: Protocol.Network.webSocketClosedPayload) { + this._timestampBaselineForWebSocket.delete(event.requestId); + this._page.frameManager.webSocketClosed(event.requestId); + } + + _timestampToWallTimeMsForWebSocket(requestId: string, timestamp: number): number { + return this._timestampBaselineForWebSocket.get(requestId)! + timestamp; + } + async _grantPermissions(origin: string, permissions: string[]) { const webPermissionToProtocol = new Map([ ['geolocation', 'geolocation'], diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index bd2c603025f11..4a083a529e679 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -343,6 +343,24 @@ test.describe('browser', () => { await page.close(); }); + test('should not intercept TLS for origins without a client certificate', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41106' }, + }, async ({ browser, asset, httpsServer }) => { + // If the proxy intercepted this origin, the browser would see its self-signed cert (CN=localhost) + // instead of the real server cert (CN=playwright-test). + const page = await browser.newPage({ + clientCertificates: [{ + origin: 'https://not-matching.com', + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + const response = await page.goto(httpsServer.EMPTY_PAGE); + expect(response.ok()).toBe(true); + expect((await response.securityDetails()).subjectName).toBe('playwright-test'); + await page.close(); + }); + test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName, isMac }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && isMac }); const page = await browser.newPage({ @@ -636,13 +654,13 @@ test.describe('browser', () => { await new Promise(resolve => server.listen(0, 'localhost', resolve)); const port = (server.address() as net.AddressInfo).port; - const origin = 'https://' + (browserName === 'webkit' && platform === 'darwin' ? 'local.playwright' : 'localhost'); - const serverUrl = `${origin}:${port}`; + const host = browserName === 'webkit' && platform === 'darwin' ? 'local.playwright' : 'localhost'; + const serverUrl = `https://${host}:${port}`; const context = await browser.newContext({ ignoreHTTPSErrors: true, clientCertificates: [{ - origin, + origin: serverUrl, certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'), }], diff --git a/tests/library/har-websocket.spec.ts b/tests/library/har-websocket.spec.ts index 9b6edb24b9289..012ac69821833 100644 --- a/tests/library/har-websocket.spec.ts +++ b/tests/library/har-websocket.spec.ts @@ -36,6 +36,26 @@ async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => }; } +function headersSize(headers: { name: string, value: string }[]): number { + let result = 0; + for (const header of headers) + result += header.name.length + ': '.length + header.value.length + '\r\n'.length; + return result; +} + +function requestHeadersSize(headers: { name: string, value: string }[]): number { + let result = 'GET /ws HTTP/1.1\r\n'.length; + result += headersSize(headers); + return result; +} + +function responseHeadersSize(headers: { name: string, value: string }[]): number { + let result = 'HTTP/1.1 101 Switching Protocols\r\n'.length; + result += headersSize(headers); + result += '\r\n'.length; + return result; +} + it('should only have one websocket entry', async ({ contextFactory, server, browserName }, testInfo) => { server.onceWebSocketConnection(ws => { ws.on('message', () => ws.close()); @@ -76,8 +96,10 @@ it('should include websocket handshake headers and status', async ({ contextFact const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.request.headersSize).toBe(requestHeadersSize(wsEntry.request.headers)); expect(wsEntry.response.status).toBe(101); expect(wsEntry.response.statusText).toBe('Switching Protocols'); + expect(wsEntry.response.headersSize).toBe(responseHeadersSize(wsEntry.response.headers)); const requestHeaderNames = wsEntry.request.headers.map(h => h.name.toLowerCase()); expect(requestHeaderNames).toContain('upgrade'); @@ -94,8 +116,11 @@ it('should include websocket handshake headers and status', async ({ contextFact }); it('should include websocket messages', async ({ contextFactory, server }, testInfo) => { + const incoming = 'x'.repeat(125); + const outgoing = 'outgoing'; + server.onceWebSocketConnection(ws => { - ws.on('message', () => ws.send('incoming')); + ws.on('message', () => ws.send(incoming)); }); const { page, getLog } = await pageWithHar(contextFactory, testInfo); @@ -103,25 +128,102 @@ it('should include websocket messages', async ({ contextFactory, server }, testI const beforeMs = Date.now(); const wsUrl = `ws://${server.HOST}/ws`; - const closed = page.evaluate(url => new Promise(resolve => { + const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { const ws = new WebSocket(url); - ws.addEventListener('open', () => ws.send('outgoing')); + ws.addEventListener('open', () => ws.send(outgoing)); ws.addEventListener('message', () => ws.close()); ws.addEventListener('close', () => resolve()); - }), wsUrl); + }), { url: wsUrl, outgoing }); await closed; const afterMs = Date.now(); const log = await getLog(); const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; - expect(wsEntry._resourceType).toBe('websocket'); - expect(wsEntry.response.status).toBe(101); - expect(wsEntry.response.statusText).toBe('Switching Protocols'); + // The payload is short enough that they only need the minimum frame header size. + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + 6 + incoming.length); + + const messages = wsEntry._webSocketMessages; + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.data }))).toEqual([ + { type: 'send', opcode: 1, data: outgoing }, + { type: 'receive', opcode: 1, data: incoming }, + ]); + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); + expect(m.time).toBeLessThanOrEqual(afterMs + 1); + } + expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); +}); + +it('should include larger websocket messages', async ({ contextFactory, server }, testInfo) => { + const incoming = 'x'.repeat(126); + const outgoing = 'outgoing'; + + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.send(incoming)); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const beforeMs = Date.now(); + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send(outgoing)); + ws.addEventListener('message', () => ws.close()); + ws.addEventListener('close', () => resolve()); + }), { url: wsUrl, outgoing }); + await closed; + const afterMs = Date.now(); + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + // The payload is large enough that additional bytes are needed to represent the payload length. + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + 6 + 2 + incoming.length); + + const messages = wsEntry._webSocketMessages; + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.data }))).toEqual([ + { type: 'send', opcode: 1, data: outgoing }, + { type: 'receive', opcode: 1, data: incoming }, + ]); + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); + expect(m.time).toBeLessThanOrEqual(afterMs + 1); + } + expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); +}); + +it('should include gigantic websocket messages', async ({ contextFactory, server }, testInfo) => { + const incoming = 'x'.repeat(2 ** 16 + 1); + const outgoing = 'outgoing'; + + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.send(incoming)); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const beforeMs = Date.now(); + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send(outgoing)); + ws.addEventListener('message', () => ws.close()); + ws.addEventListener('close', () => resolve()); + }), { url: wsUrl, outgoing }); + await closed; + const afterMs = Date.now(); + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + // The payload is large enough that additional bytes are needed to represent the payload length. + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + 6 + 8 + incoming.length); const messages = wsEntry._webSocketMessages; expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.data }))).toEqual([ - { type: 'send', opcode: 1, data: 'outgoing' }, - { type: 'receive', opcode: 1, data: 'incoming' }, + { type: 'send', opcode: 1, data: outgoing }, + { type: 'receive', opcode: 1, data: incoming }, ]); for (const m of messages) { expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); @@ -141,6 +243,7 @@ it('should include binary websocket messages', async ({ contextFactory, server } const { page, getLog } = await pageWithHar(contextFactory, testInfo); await page.goto(server.EMPTY_PAGE); + const beforeMs = Date.now(); const wsUrl = `ws://${server.HOST}/ws`; const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { const ws = new WebSocket(url); @@ -150,21 +253,61 @@ it('should include binary websocket messages', async ({ contextFactory, server } ws.addEventListener('close', () => resolve()); }), { url: wsUrl, outgoing }); await closed; + const afterMs = Date.now(); const log = await getLog(); const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; - expect(wsEntry._resourceType).toBe('websocket'); - expect(wsEntry.response.status).toBe(101); - expect(wsEntry.response.statusText).toBe('Switching Protocols'); + // The payload is short enough that they only need the minimum frame header size. + expect(wsEntry.response._transferSize).toBe(responseHeadersSize(wsEntry.response.headers) + 6 + incoming.length); const messages = wsEntry._webSocketMessages; - expect(messages.length).toBe(2); - expect(messages[0].type).toBe('send'); - expect(messages[0].opcode).toBe(2); - expect([...Buffer.from(messages[0].data, 'base64')]).toEqual(outgoing); - expect(messages[1].type).toBe('receive'); - expect(messages[1].opcode).toBe(2); - expect([...Buffer.from(messages[1].data, 'base64')]).toEqual(incoming); + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: [...Buffer.from(m.data, 'base64')] }))).toEqual([ + { type: 'send', opcode: 2, data: outgoing }, + { type: 'receive', opcode: 2, data: incoming }, + ]); + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs - 1); + expect(m.time).toBeLessThanOrEqual(afterMs + 1); + } + expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); +}); + +it('should include websocket entry time across multiple messages', async ({ contextFactory, server }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', message => { + switch (message.toString()) { + case 'a': + setTimeout(() => ws.send('b'), 50); + break; + case 'b': + setTimeout(() => ws.send('c'), 50); + break; + case 'c': + setTimeout(() => ws.close(), 50); + break; + } + }); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const beforeMs = Date.now(); + const closed = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('a')); + ws.addEventListener('message', e => ws.send(e.data)); + ws.addEventListener('close', () => resolve()); + }), wsUrl); + await closed; + const afterMs = Date.now(); + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + const messages = wsEntry._webSocketMessages; + expect(wsEntry.time).toBeGreaterThanOrEqual(messages[messages.length - 1].time - messages[0].time); + expect(wsEntry.time).toBeLessThanOrEqual(afterMs - beforeMs); }); it('should record websocket connection failure', async ({ contextFactory, server }, testInfo) => {