Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(source-maps): workaround CORS for fetching maps #9459

Merged
merged 23 commits into from
Mar 17, 2020
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lighthouse-cli/test/fixtures/source-map/script.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions lighthouse-cli/test/fixtures/source-map/source-map-tester.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!--
* Copyright 2019 Google Inc. All Rights Reserved.
* 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.
-->
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Source maps tester</title>
</head>

<body>
<script>
// Test that source maps work.
//# sourceMappingURL=http://localhost:10200/source-map/script.js.map
patrickhulce marked this conversation as resolved.
Show resolved Hide resolved
</script>

<script>
// Test that source maps work when on a different origin (CORS).
//# sourceMappingURL=http://localhost:10503/source-map/script.js.map
</script>

map time map time! map time map time! 🎉
</body>

</html>
5 changes: 5 additions & 0 deletions lighthouse-cli/test/smokehouse/smoke-test-dfns.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ const SMOKE_TEST_DFNS = [{
expectations: 'tricky-metrics/expectations.js',
config: 'lighthouse-core/config/perf-config.js',
batch: 'parallel-second',
}, {
id: 'source-maps',
expectations: 'source-map/expectations.js',
config: 'source-map/config.js',
batch: 'parallel-first',
}];

/**
Expand Down
26 changes: 26 additions & 0 deletions lighthouse-cli/test/smokehouse/source-map/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @license Copyright 2019 Google Inc. All Rights Reserved.
* 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.
*/
'use strict';

/**
* Config file for running source map smokehouse.
*/

// source-maps currently isn't in the default config yet, so we make a new one with it.
// Also, no audits use source-maps yet, and at least one is required for a successful run,
// so `viewport` and its required gatherer `meta-elements` is used.

/** @type {LH.Config.Json} */
module.exports = {
passes: [{
passName: 'defaultPass',
gatherers: [
'source-maps',
'meta-elements',
],
}],
audits: ['viewport'],
};
39 changes: 39 additions & 0 deletions lighthouse-cli/test/smokehouse/source-map/expectations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @license Copyright 2019 Google Inc. All Rights Reserved.
* 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.
*/
'use strict';

const fs = require('fs');

const mapPath = require.resolve('../../fixtures/source-map/script.js.map');
const mapJson = fs.readFileSync(mapPath, 'utf-8');
const map = JSON.parse(mapJson);

/**
* Expected Lighthouse results for source maps.
*/
module.exports = [
{
artifacts: {
SourceMaps: [
{
scriptUrl: 'http://localhost:10200/source-map/source-map-tester.html',
sourceMapUrl: 'http://localhost:10200/source-map/script.js.map',
map,
},
{
scriptUrl: 'http://localhost:10200/source-map/source-map-tester.html',
sourceMapUrl: 'http://localhost:10503/source-map/script.js.map',
map,
},
],
},
lhr: {
requestedUrl: 'http://localhost:10200/source-map/source-map-tester.html',
finalUrl: 'http://localhost:10200/source-map/source-map-tester.html',
audits: {},
},
},
];
149 changes: 149 additions & 0 deletions lighthouse-core/gather/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ class Driver {
* @private
*/
this._nextProtocolTimeout = DEFAULT_PROTOCOL_TIMEOUT;

this._onRequestPaused = this._onRequestPaused.bind(this);

/** @type {Map<string, (event: LH.Crdp.Fetch.RequestPausedEvent) => void>} */
this._onRequestPausedHandlers = new Map();
}

static get traceCategories() {
Expand Down Expand Up @@ -1581,6 +1586,150 @@ class Driver {

await this.sendCommand('Page.enable');
}

/**
* The Fetch domain accepts patterns for controlling what requests are intercepted, but we
* enable the domain for all patterns and filter events at a lower level to support multiple
* concurrent usages. Reasons for this:
*
* 1) only one set of patterns may be applied for the entire domain.
* 2) every request that matches the patterns are paused and only resumes when certain Fetch
* commands are sent. So a listener of the `Fetch.requestPaused` event must either handle
* the requests it cares about, or explicitly allow them to continue.
* 3) if multiple commands to continue the same request are sent, protocol errors occur.
*
* So instead we have one global `Fetch.enable` / `Fetch.requestPaused` pair, and allow specific
* urls to be intercepted via `driver.setOnRequestPausedHandler`.
*/
async enableRequestInterception() {
connorjclark marked this conversation as resolved.
Show resolved Hide resolved
await this.sendCommand('Fetch.enable', {
patterns: [{requestStage: 'Request'}, {requestStage: 'Response'}],
});
await this.on('Fetch.requestPaused', this._onRequestPaused);
}

/**
* @param {string} url
* @param {(event: LH.Crdp.Fetch.RequestPausedEvent) => void} handler
*/
async setOnRequestPausedHandler(url, handler) {
this._onRequestPausedHandlers.set(url, handler);
}

/**
* @param {LH.Crdp.Fetch.RequestPausedEvent} event
*/
async _onRequestPaused(event) {
const handler = this._onRequestPausedHandlers.get(event.request.url);
if (handler) {
await handler(event);
} else {
// Nothing cares about this URL, so continue.
await this.sendCommand('Fetch.continueRequest', {requestId: event.requestId});
}
}

async disableRequestInterception() {
await this.sendCommand('Fetch.disable');
await this.off('Fetch.requestPaused', this._onRequestPaused);
this._onRequestPausedHandlers.clear();
}

/**
* Requires that `driver.enableRequestInterception` has been called.
*
* Fetches any resource in a way that circumvents CORS.
*
* @param {string} url
* @param {number} timeoutInMs
connorjclark marked this conversation as resolved.
Show resolved Hide resolved
* @return {Promise<string>}
*/
async fetchArbitraryResource(url, timeoutInMs = 500) {
if (!this.isDomainEnabled('Fetch')) {
connorjclark marked this conversation as resolved.
Show resolved Hide resolved
throw new Error('Fetch domain must be enabled to use fetchArbitraryResource');
}

/** @type {Promise<string>} */
const requestInterceptionPromise = new Promise((resolve, reject) => {
this.setOnRequestPausedHandler(url, async (event) => {
const {requestId, responseStatusCode} = event;

// The first requestPaused event is for the request stage. Continue it.
if (!responseStatusCode) {
// Remove same-site cookies so we aren't buying stuff on Amazon.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we're not concerned with the sort of manifest/user profile state being stored in a samesite cookie that made us want to deal with cookies at all in the first place?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk how cookies work tbh. so you're saying this bit of code totally invalidates any source maps behind cookie-authentication?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any cookie auth that they've marked as SameSite, yeah

marking auth cookies as samesite is a decent way to prevent the annoying site-style logout/language attack

const sameSiteCookies = await this.sendCommand('Network.getCookies', {urls: [url]});
const sameSiteCookiesKeyValueSet = new Set();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could split into its own fn for easier testing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

punting on testing b/c i don't really know how the cookie story plays out here, may be this is just removed and we totally delete all cookies?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to keep it simple and safe at first I would propose deleting all cookies, until we observe the need for them

for (const cookie of sameSiteCookies.cookies) {
sameSiteCookiesKeyValueSet.add(cookie.name + '=' + cookie.value);
}
const strippedCookies = event.request.headers['Cookie']
.split(';')
.filter(cookieKeyValue => {
return !sameSiteCookiesKeyValueSet.has(cookieKeyValue.trim());
})
.join('; ');

this.sendCommand('Fetch.continueRequest', {
requestId,
headers: [{name: 'Cookie', value: strippedCookies}],
});
return;
}

// Now in the response stage, but the request failed.
if (!(responseStatusCode >= 200 && responseStatusCode < 300)) {
reject(new Error(`Invalid response status code: ${responseStatusCode}`));
return;
}

const responseBody = await this.sendCommand('Fetch.getResponseBody', {requestId});
if (responseBody.base64Encoded) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this just based on if chrome thinks the resource is text-based or not?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, IDK how the agent decides but there is no way to configure it from the protocol.

resolve(Buffer.from(responseBody.body, 'base64').toString());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels like a tricky way to handle buffers, could we return a complex type {buffer: Buffer}|{text: string} or something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe return both cases as buffer?

instead of the string case: Buffer.from(bufStr, 'utf8');

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we currently fetch the robots.txt, sourcemaps, the start_url (HTML)

so we dont need non-text resources at all right now. fail any base64Encoded and we'll handle it later if we need to actually get these payloads

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sgtm

} else {
resolve(responseBody.body);
}

// Fail the request (from the page's perspective) so that the iframe never loads.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you remind me (perhaps in a comment) why we do this?

Copy link
Collaborator Author

@connorjclark connorjclark Jul 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't have any more to add than what the comment says. I assume Blink will do more work if the iframe actually gets its contents loaded, even if all the styling on it makes it not visible. but i'm just guessing.

this.sendCommand('Fetch.failRequest', {requestId, errorReason: 'Aborted'});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aborted by Lighthouse?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is an enum, can't give custom messages.

});
});

/**
* @param {string} src
*/
/* istanbul ignore next */
function injectIframe(src) {
/** @type {HTMLIFrameElement} */
const iframe = document.createElement('iframe');
// Try really hard not to affect the page.
iframe.style.display = 'none';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

think you also want to set top.

 iframe.style.cssText = `
display: none;
visibility: hidden;
position: absolute;
left: -100px;
top: -100px;
width: 1px;
height: 1px;
`;

iframe.style.position = 'absolute';
iframe.style.left = '10000px';
iframe.style.visibility = 'hidden';
iframe.src = src;
iframe.onload = iframe.onerror = () => {
iframe.remove();
delete iframe.onload;
delete iframe.onerror;
};
document.body.appendChild(iframe);
}

await this.evaluateAsync(`${injectIframe}(${JSON.stringify(url)})`);
connorjclark marked this conversation as resolved.
Show resolved Hide resolved

/** @type {NodeJS.Timeout} */
let timeoutHandle;
/** @type {Promise<never>} */
const timeoutPromise = new Promise((_, reject) => {
const errorMessage = 'Timed out fetching resource.';
timeoutHandle = setTimeout(() => reject(new Error(errorMessage)), timeoutInMs);
});

return Promise.race([
timeoutPromise,
requestInterceptionPromise,
]).finally(() => clearTimeout(timeoutHandle));
}
}

module.exports = Driver;
31 changes: 5 additions & 26 deletions lighthouse-core/gather/gatherers/source-maps.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,6 @@
const Gatherer = require('./gatherer.js');
const URL = require('../../lib/url-shim.js');

/**
* This function fetches source maps; it is careful not to parse the response as JSON, as it will
* just need to be serialized again over the protocol, and source maps can
* be huge.
*
* @param {string} url
* @return {Promise<string>}
*/
/* istanbul ignore next */
async function fetchSourceMap(url) {
// eslint-disable-next-line no-undef
const response = await fetch(url);
if (response.ok) {
return response.text();
} else {
throw new Error(`Received status code ${response.status} for ${url}`);
}
}

/**
* @fileoverview Gets JavaScript source maps.
*/
Expand All @@ -45,11 +26,9 @@ class SourceMaps extends Gatherer {
* @param {string} sourceMapUrl
* @return {Promise<LH.Artifacts.RawSourceMap>}
*/
async fetchSourceMapInPage(driver, sourceMapUrl) {
driver.setNextProtocolTimeout(1500);
async fetchSourceMap(driver, sourceMapUrl) {
/** @type {string} */
const sourceMapJson =
await driver.evaluateAsync(`(${fetchSourceMap})(${JSON.stringify(sourceMapUrl)})`);
const sourceMapJson = await driver.fetchArbitraryResource(sourceMapUrl, 1500);
return JSON.parse(sourceMapJson);
}

Expand Down Expand Up @@ -124,7 +103,7 @@ class SourceMaps extends Gatherer {
try {
const map = isSourceMapADataUri ?
this.parseSourceMapFromDataUrl(rawSourceMapUrl) :
await this.fetchSourceMapInPage(driver, rawSourceMapUrl);
await this.fetchSourceMap(driver, rawSourceMapUrl);
return {
scriptUrl,
sourceMapUrl,
Expand All @@ -149,10 +128,10 @@ class SourceMaps extends Gatherer {
driver.off('Debugger.scriptParsed', this.onScriptParsed);
await driver.sendCommand('Debugger.disable');

await driver.enableRequestInterception();
const eventProcessPromises = this._scriptParsedEvents
.map((event) => this._retrieveMapFromScriptParsedEvent(driver, event));

return Promise.all(eventProcessPromises);
return Promise.all(eventProcessPromises).finally(() => driver.disableRequestInterception());
}
}

Expand Down
Loading