From efb21b9e9fe10f8b98d6dda49ef156c38e372549 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 9 Jul 2021 05:41:53 -0700 Subject: [PATCH] browser(firefox): support response interception (#7509) --- browser_patches/firefox/BUILD_NUMBER | 4 +- .../firefox/juggler/NetworkObserver.js | 161 +++++++++++++----- .../firefox/juggler/protocol/PageHandler.js | 4 +- .../firefox/juggler/protocol/Protocol.js | 10 ++ 4 files changed, 136 insertions(+), 43 deletions(-) diff --git a/browser_patches/firefox/BUILD_NUMBER b/browser_patches/firefox/BUILD_NUMBER index 715f52bd8e..477eab99ed 100644 --- a/browser_patches/firefox/BUILD_NUMBER +++ b/browser_patches/firefox/BUILD_NUMBER @@ -1,2 +1,2 @@ -1274 -Changed: dgozman@gmail.com Tue Jun 30 16:15:40 PDT 2021 +1275 +Changed: yurys@chromium.org Thu Jul 8 13:25:54 MSK 2021 diff --git a/browser_patches/firefox/juggler/NetworkObserver.js b/browser_patches/firefox/juggler/NetworkObserver.js index d10eeff64a..1f3a11a3d4 100644 --- a/browser_patches/firefox/juggler/NetworkObserver.js +++ b/browser_patches/firefox/juggler/NetworkObserver.js @@ -73,8 +73,15 @@ class PageNetwork { this._interceptedRequests.clear(); } - resumeInterceptedRequest(requestId, url, method, headers, postData) { + async resumeInterceptedRequest(requestId, url, method, headers, postData, interceptResponse) { + if (interceptResponse) { + const intercepted = this._interceptedRequests.get(requestId); + if (!intercepted) + throw new Error(`Cannot find request "${requestId}"`); + return { response: await intercepted.interceptResponse(url, method, headers, postData) }; + } this._takeIntercepted(requestId).resume(url, method, headers, postData); + return {}; } fulfillInterceptedRequest(requestId, status, statusText, headers, base64body) { @@ -180,6 +187,64 @@ class NetworkRequest { this._interceptedChannel = undefined; } + async interceptResponse(url, method, headers, postData) { + const uri = url ? Services.io.newURI(url) : this.httpChannel.URI; + const newChannel = NetUtil.newChannel({ + uri, + loadingNode: this.httpChannel.loadInfo.loadingContext, + loadingPrincipal: this.httpChannel.loadInfo.loadingPrincipal || this._interceptedChannel.loadInfo.principalToInherit, + triggeringPrincipal: this.httpChannel.loadInfo.triggeringPrincipal, + securityFlags: this.httpChannel.loadInfo.securityFlags, + contentPolicyType: this.httpChannel.loadInfo.internalContentPolicyType, + }).QueryInterface(Ci.nsIRequest).QueryInterface(Ci.nsIHttpChannel); + newChannel.loadInfo = this.httpChannel.loadInfo; + newChannel.loadGroup = this.httpChannel.loadGroup; + + for (const header of (headers || requestHeaders(this.httpChannel))) + newChannel.setRequestHeader(header.name, header.value, false /* merge */); + + if (postData) { + setPostData(newChannel, postData, headers); + } else if (this.httpChannel instanceof Ci.nsIUploadChannel) { + newChannel.QueryInterface(Ci.nsIUploadChannel); + newChannel.setUploadStream(this.httpChannel.uploadStream, '', -1); + } + // We must set this after setting the upload stream, otherwise it + // will always be 'PUT'. (from another place in the source base) + newChannel.requestMethod = method || this.httpChannel.requestMethod; + + this._networkObserver._responseInterceptionChannels.add(newChannel); + const body = await new Promise((resolve, reject) => { + NetUtil.asyncFetch(newChannel, (stream, status) => { + this._networkObserver._responseInterceptionChannels.delete(newChannel); + if (!Components.isSuccessCode(status)) { + reject(status); + return; + } + try { + resolve(NetUtil.readInputStreamToString(stream, stream.available())); + } catch (e) { + if (e.result == Cr.NS_BASE_STREAM_CLOSED) { + // The stream was empty. + resolve(''); + } else { + reject(e); + } + } finally { + stream.close(); + } + }); + }); + + const pageNetwork = this._activePageNetwork(); + if (pageNetwork) + pageNetwork._responseStorage.addResponseBody(this, newChannel, body); + + const response = responseHead(newChannel); + this._interceptedResponse = Object.assign({ body }, response); + return response; + } + // Public interception API. abort(errorCode) { const error = errorMap[errorCode] || Cr.NS_ERROR_FAILURE; @@ -189,6 +254,13 @@ class NetworkRequest { // Public interception API. fulfill(status, statusText, headers, base64body) { + let body = base64body ? atob(base64body) : ''; + if (this._interceptedResponse) { + status = status || this._interceptedResponse.status; + statusText = statusText || this._interceptedResponse.statusText; + headers = headers || this._interceptedResponse.headers; + body = body || this._interceptedResponse.body; + } this._interceptedChannel.synthesizeStatus(status, statusText); for (const header of headers) { this._interceptedChannel.synthesizeHeader(header.name, header.value); @@ -198,7 +270,6 @@ class NetworkRequest { } } const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); - const body = base64body ? atob(base64body) : ''; synthesized.data = body; this._interceptedChannel.startSynthesizedResponse(synthesized, null, null, '', false); this._interceptedChannel.finishSynthesizedResponse(); @@ -233,25 +304,8 @@ class NetworkRequest { } if (method) this.httpChannel.requestMethod = method; - if (postData && this.httpChannel instanceof Ci.nsIUploadChannel2) { - const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); - const body = atob(postData); - synthesized.setData(body, body.length); - - const overriddenHeader = (lowerCaseName, defaultValue) => { - if (headers) { - for (const header of headers) { - if (header.name.toLowerCase() === lowerCaseName) { - return header.value; - } - } - } - return defaultValue; - } - // Clear content-length, so that upload stream resets it. - this.httpChannel.setRequestHeader('content-length', overriddenHeader('content-length', ''), false /* merge */); - this.httpChannel.explicitSetUploadStream(synthesized, overriddenHeader('content-type', 'application/octet-stream'), -1, this.httpChannel.requestMethod, false); - } + if (postData) + setPostData(this.httpChannel, postData, headers); } // Instrumentation called by NetworkObserver. @@ -437,7 +491,7 @@ class NetworkRequest { const body = this._responseBodyChunks.join(''); const pageNetwork = this._activePageNetwork(); if (pageNetwork) - pageNetwork._responseStorage.addResponseBody(this, body); + pageNetwork._responseStorage.addResponseBody(this, this.httpChannel, body); this._sendOnRequestFinished(); } else { this._sendOnRequestFailed(aStatusCode); @@ -529,20 +583,7 @@ class NetworkRequest { responseStart: this.httpChannel.responseStartTime, }; - const headers = []; - let status = opt_statusCode || 0; - let statusText = opt_statusText || ''; - try { - status = this.httpChannel.responseStatus; - statusText = this.httpChannel.responseStatusText; - this.httpChannel.visitResponseHeaders({ - visitHeader: (name, value) => headers.push({name, value}), - }); - } catch (e) { - // Response headers, status and/or statusText are not available - // when redirect did not actually hit the network. - } - + const { status, statusText, headers } = responseHead(this.httpChannel, opt_statusCode, opt_statusText); let remoteIPAddress = undefined; let remotePort = undefined; try { @@ -602,6 +643,7 @@ class NetworkObserver { this._channelToRequest = new Map(); // http channel -> network request this._expectedRedirect = new Map(); // expected redirect channel id (string) -> network request + this._responseInterceptionChannels = new Set(); // http channels created for response interception const protocolProxyService = Cc['@mozilla.org/network/protocol-proxy-service;1'].getService(); this._channelProxyFilter = { @@ -684,6 +726,8 @@ class NetworkObserver { _onRequest(channel, topic) { if (!(channel instanceof Ci.nsIHttpChannel)) return; + if (this._responseInterceptionChannels.has(channel)) + return; const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); const channelId = httpChannel.channelId + ''; const redirectedFrom = this._expectedRedirect.get(channelId); @@ -810,7 +854,7 @@ class ResponseStorage { this._responses = new Map(); } - addResponseBody(request, body) { + addResponseBody(request, httpChannel, body) { if (body.length > this._maxResponseSize) { this._responses.set(request.requestId, { evicted: true, @@ -819,8 +863,8 @@ class ResponseStorage { return; } let encodings = []; - if ((request.httpChannel instanceof Ci.nsIEncodedChannel) && request.httpChannel.contentEncodings && !request.httpChannel.applyConversion) { - const encodingHeader = request.httpChannel.getResponseHeader("Content-Encoding"); + if ((httpChannel instanceof Ci.nsIEncodedChannel) && httpChannel.contentEncodings && !httpChannel.applyConversion) { + const encodingHeader = httpChannel.getResponseHeader("Content-Encoding"); encodings = encodingHeader.split(/\s*\t*,\s*\t*/); } this._responses.set(request.requestId, {body, encodings}); @@ -851,6 +895,45 @@ class ResponseStorage { } } +function responseHead(httpChannel, opt_statusCode, opt_statusText) { + const headers = []; + let status = opt_statusCode || 0; + let statusText = opt_statusText || ''; + try { + status = httpChannel.responseStatus; + statusText = httpChannel.responseStatusText; + httpChannel.visitResponseHeaders({ + visitHeader: (name, value) => headers.push({name, value}), + }); + } catch (e) { + // Response headers, status and/or statusText are not available + // when redirect did not actually hit the network. + } + return { status, statusText, headers }; +} + +function setPostData(httpChannel, postData, headers) { + if (!(httpChannel instanceof Ci.nsIUploadChannel2)) + return; + const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); + const body = atob(postData); + synthesized.setData(body, body.length); + + const overriddenHeader = (lowerCaseName, defaultValue) => { + if (headers) { + for (const header of headers) { + if (header.name.toLowerCase() === lowerCaseName) { + return header.value; + } + } + } + return defaultValue; + } + // Clear content-length, so that upload stream resets it. + httpChannel.setRequestHeader('content-length', overriddenHeader('content-length', ''), false /* merge */); + httpChannel.explicitSetUploadStream(synthesized, overriddenHeader('content-type', 'application/octet-stream'), -1, httpChannel.requestMethod, false); +} + function convertString(s, source, dest) { const is = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( Ci.nsIStringInputStream diff --git a/browser_patches/firefox/juggler/protocol/PageHandler.js b/browser_patches/firefox/juggler/protocol/PageHandler.js index b7c17106b4..fa159246af 100644 --- a/browser_patches/firefox/juggler/protocol/PageHandler.js +++ b/browser_patches/firefox/juggler/protocol/PageHandler.js @@ -259,8 +259,8 @@ class PageHandler { this._pageNetwork.disableRequestInterception(); } - async ['Network.resumeInterceptedRequest']({requestId, url, method, headers, postData}) { - this._pageNetwork.resumeInterceptedRequest(requestId, url, method, headers, postData); + async ['Network.resumeInterceptedRequest']({requestId, url, method, headers, postData, interceptResponse}) { + return await this._pageNetwork.resumeInterceptedRequest(requestId, url, method, headers, postData, interceptResponse); } async ['Network.abortInterceptedRequest']({requestId, errorCode}) { diff --git a/browser_patches/firefox/juggler/protocol/Protocol.js b/browser_patches/firefox/juggler/protocol/Protocol.js index d179f93cd4..3606f9e79e 100644 --- a/browser_patches/firefox/juggler/protocol/Protocol.js +++ b/browser_patches/firefox/juggler/protocol/Protocol.js @@ -205,6 +205,12 @@ networkTypes.ResourceTiming = { responseStart: t.Number, }; +networkTypes.InterceptedResponse = { + status: t.Number, + statusText: t.String, + headers: t.Array(networkTypes.HTTPHeader), +}; + const Browser = { targets: ['browser'], @@ -524,6 +530,10 @@ const Network = { method: t.Optional(t.String), headers: t.Optional(t.Array(networkTypes.HTTPHeader)), postData: t.Optional(t.String), + interceptResponse: t.Optional(t.Boolean), + }, + returns: { + response: t.Optional(networkTypes.InterceptedResponse), }, }, 'fulfillInterceptedRequest': {