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: bail if encounter insecure ssl cert, to avoid hanging forever #6300

Merged
merged 13 commits into from
Oct 19, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 18 additions & 0 deletions lighthouse-core/gather/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,24 @@ class Driver {
});
}

/**
* @param {number} [timeout]
* @return {Promise<LH.Crdp.Security.SecurityStateChangedEvent>}
*/
getSecurityState(timeout = 1000) {
return new Promise((resolve, reject) => {
const err = new LHError(LHError.errors.SECURITY_STATE_TIMEOUT);
const asyncTimeout = setTimeout((_ => reject(err)), timeout);
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 will eventually be replaced by #6296


this.sendCommand('Security.enable');
Copy link
Collaborator

Choose a reason for hiding this comment

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

also, @paulirish is Security domain still broken on Android, is this going to break us on real devices again?

Copy link
Member

Choose a reason for hiding this comment

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

they fixed android like a year ago! yay

image

Copy link
Collaborator

Choose a reason for hiding this comment

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

well someone's behind the times 😆

this.once('Security.securityStateChanged', (e) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

somewhat tangential...

@paulirish are we just paranoid for doing all our listeners before enabling or do we actually need to? I seem to remember dgozman scolding me for not trusting in JS microtasks when we had listeners before .enable :)

Copy link
Member

Choose a reason for hiding this comment

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

hmm good call.

This code here would be a problem if there was an await on the .enable call above. which seems fairly reasonable.

So yeah +1 to defining the once handler before we flip enable() on

Copy link
Collaborator Author

@connorjclark connorjclark Oct 17, 2018

Choose a reason for hiding this comment

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

good point, fixed.

I don't quite understand the execution model here. It's clear to me why awaiting the enable would schedule registering the listener much too late, but I don't understand how this original code worked (enable without awaiting, then register a listener via .once). Why does enabling the Security domain still occur after the listener is registered?

(note, this is literally all I know about micro/macrotasks: https://stackoverflow.com/a/25933985 )

clearTimeout(asyncTimeout);
resolve(e);
Copy link
Member

Choose a reason for hiding this comment

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

e => state ?

this.sendCommand('Security.disable').catch(reject);
Copy link
Member

Choose a reason for hiding this comment

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

I don't think you need this catch(). The timeout one is fine.

});
});
}

/**
* @param {string} name The name of API whose permission you wish to query
* @return {Promise<string>} The state of permissions, resolved in a promise.
Expand Down
42 changes: 34 additions & 8 deletions lighthouse-core/gather/gather-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,12 @@ class GatherRunner {

/**
* Returns an error if the original network request failed or wasn't found.
* @param {Driver} driver
* @param {string} url The URL of the original requested page.
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {LHError|undefined}
* @return {Promise<LHError|undefined>}
*/
static getPageLoadError(url, networkRecords) {
static async getPageLoadError(driver, url, networkRecords) {
const mainRecord = networkRecords.find(record => {
// record.url is actual request url, so needs to be compared without any URL fragment.
return URL.equalWithExcludedFragments(record.url, url);
Expand All @@ -165,6 +166,20 @@ class GatherRunner {
} else if (mainRecord.hasErrorStatusCode()) {
errorDef = {...LHError.errors.ERRORED_DOCUMENT_REQUEST};
errorDef.message += ` Status code: ${mainRecord.statusCode}.`;
} else if (!mainRecord.finished) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

AFAIK, an insecure security state will always have .finished set to false on the network records. I wanted to avoid calling these Security commands if not needed.

// could be security error
Copy link
Member

Choose a reason for hiding this comment

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

I think this may be just certificate errors. Seems like most of the connection errors are mainRecord.failed === true.

const securityState = await driver.getSecurityState();
Copy link
Member

Choose a reason for hiding this comment

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

Small nit but I think it'd be slightly better to collect this on L288 and pass that into getPageLoadError() rather than all of driver.

Copy link
Collaborator

Choose a reason for hiding this comment

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

super nit: WDYT about sprinkling some nice destructuring here, const {securityState, explanations} =? somethin' about securityState.securityState just spoke to me :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I didn't even notice that :O

if (securityState.securityState === 'insecure') {
errorDef = {...LHError.errors.INSECURE_DOCUMENT_REQUEST};
const insecureDescriptions = securityState.explanations
.filter(exp => exp.securityState === 'insecure')
.map(exp => exp.description)
.join(' ');
errorDef.message += ` Insecure: ${insecureDescriptions}`;
Copy link
Member

Choose a reason for hiding this comment

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

Agree the explanation gathering is the best we can do.
For this line i think it could just be

Suggested change
errorDef.message += ` Insecure: ${insecureDescriptions}`;
errorDef.message += ` ${insecureDescriptions.join(' ')}`;

} else {
// Not sure what the error is. Could be just a redirect. For now,
// treat as no error.
}
}

if (errorDef) {
Expand Down Expand Up @@ -251,7 +266,7 @@ class GatherRunner {
* object containing trace and network data.
* @param {LH.Gatherer.PassContext} passContext
* @param {Partial<GathererResults>} gathererResults
* @return {Promise<LH.Gatherer.LoadData>}
* @return {Promise<[LH.Gatherer.LoadData, LH.LighthouseError | undefined]>}
*/
static async afterPass(passContext, gathererResults) {
const driver = passContext.driver;
Expand All @@ -271,7 +286,8 @@ class GatherRunner {
const networkRecords = NetworkRecorder.recordsFromLogs(devtoolsLog);
log.verbose('statusEnd', status);

let pageLoadError = GatherRunner.getPageLoadError(passContext.url, networkRecords);
let pageLoadError = await GatherRunner.getPageLoadError(driver,
passContext.url, networkRecords);
// If the driver was offline, a page load error is expected, so do not save it.
if (!driver.online) pageLoadError = undefined;

Expand All @@ -288,8 +304,12 @@ class GatherRunner {
trace,
};

// Disable throttling so the afterPass analysis isn't throttled
Copy link
Member

Choose a reason for hiding this comment

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

restore?

await driver.setThrottling(passContext.settings, {useThrottling: false});
if (!pageLoadError) {
// Disable throttling so the afterPass analysis isn't throttled
// This will hang if there was a security error. But, there is no
// need to throttle if there is such an error. See #6287
await driver.setThrottling(passContext.settings, {useThrottling: false});
}

for (const gathererDefn of gatherers) {
const gatherer = gathererDefn.instance;
Expand All @@ -313,7 +333,7 @@ class GatherRunner {
}

// Resolve on tracing data using passName from config.
return passData;
return [passData, pageLoadError];
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

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

WDYT about attaching it to passData instead? It's a relatively grab-bag object of what a happened during a pass, so it makes some sense :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure. I'll add it as an optional property. I avoided it in the first place b/c I didn't want to modify all usages of this type, but I completely forgot about optional properties :P

}

/**
Expand Down Expand Up @@ -410,7 +430,8 @@ class GatherRunner {
await driver.setThrottling(options.settings, passConfig);
await GatherRunner.beforePass(passContext, gathererResults);
await GatherRunner.pass(passContext, gathererResults);
const passData = await GatherRunner.afterPass(passContext, gathererResults);
const [passData, pageLoadError] =
Copy link
Member

Choose a reason for hiding this comment

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

@brendankenny @patrickhulce either of you have an idea on how else to deliver this error?

see also the L462 here

Copy link
Member

Choose a reason for hiding this comment

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

pageLoadError.fatal = true; throw pageLoadError; :P

await GatherRunner.afterPass(passContext, gathererResults);

// Save devtoolsLog, but networkRecords are discarded and not added onto artifacts.
baseArtifacts.devtoolsLogs[passConfig.passName] = passData.devtoolsLog;
Expand All @@ -435,6 +456,11 @@ class GatherRunner {
baseArtifacts.URL.finalUrl = passContext.url;
firstPass = false;
}

if (pageLoadError && pageLoadError.code === LHError.errors.INSECURE_DOCUMENT_REQUEST.code) {
// Some protocol commands will hang, so let's just bail. See #6287
break;
}
}
const resetStorage = !options.settings.disableStorageReset;
if (resetStorage) await driver.clearDataForOrigin(options.requestedUrl);
Expand Down
12 changes: 12 additions & 0 deletions lighthouse-core/lib/lh-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ const ERRORS = {
message: strings.pageLoadFailed,
lhrRuntimeError: true,
},
/* Used when security error prevents page load. */
INSECURE_DOCUMENT_REQUEST: {
code: 'INSECURE_DOCUMENT_REQUEST',
message: strings.pageLoadFailedInsecure,
lhrRuntimeError: true,
},

// Protocol internal failures
TRACING_ALREADY_STARTED: {
Expand Down Expand Up @@ -169,6 +175,12 @@ const ERRORS = {
message: strings.requestContentTimeout,
},

// Protocol timeout failures
SECURITY_STATE_TIMEOUT: {
Copy link
Member

Choose a reason for hiding this comment

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

in the usual case for the security state check, it should just reject on an insecure security state, right? If so, we probably want to make this a more general protocol communication timeout error (anticipating #6296)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

My plan was to modify that bit in the referenced issue. but now that we have this concrete proto definition it makes sense to make it good sooner rather than later

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh, I actually removed the timeout stuff for security checking. I'll remove this too.

code: 'SECURITY_STATE_TIMEOUT',
message: strings.securityStateTimeout,
},

// URL parsing failures
INVALID_URL: {
code: 'INVALID_URL',
Expand Down
2 changes: 2 additions & 0 deletions lighthouse-core/lib/strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ module.exports = {
badTraceRecording: `Something went wrong with recording the trace over your page load. Please run Lighthouse again.`,
pageLoadTookTooLong: `Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse.`,
pageLoadFailed: `Lighthouse was unable to reliably load the page you requested. Make sure you are testing the correct URL and that the server is properly responding to all requests.`,
pageLoadFailedInsecure: `The URL you have provided does not have a valid security certificate.`,
Copy link
Member

Choose a reason for hiding this comment

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

hmm technically not just certificate, but admittedly most other security errors fall through the ERRORED_DOC_REQ path right now. how about

... does not have valid security credentials.

internalChromeError: `An internal Chrome error occurred. Please restart Chrome and try re-running Lighthouse.`,
requestContentTimeout: 'Fetching resource content has exceeded the allotted time',
securityStateTimeout: 'Fetching security state has exceeded the allotted time',
urlInvalid: `The URL you have provided appears to be invalid.`,
};
5 changes: 5 additions & 0 deletions lighthouse-core/test/gather/fake-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ const fakeDriver = {
setExtraHTTPHeaders() {
return Promise.resolve();
},
getSecurityState() {
return Promise.resolve({
securityState: 'secure',
});
},
};

module.exports = fakeDriver;
Expand Down
73 changes: 53 additions & 20 deletions lighthouse-core/test/gather/gather-runner-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,11 @@ describe('GatherRunner', function() {
],
};

return GatherRunner.afterPass({url, driver, passConfig}, {TestGatherer: []}).then(passData => {
assert.equal(calledTrace, true);
assert.equal(passData.trace, fakeTraceData);
});
return GatherRunner.afterPass({url, driver, passConfig}, {TestGatherer: []})
.then(([passData]) => {
assert.equal(calledTrace, true);
assert.equal(passData.trace, fakeTraceData);
});
});

it('tells the driver to begin devtoolsLog collection', () => {
Expand Down Expand Up @@ -543,10 +544,11 @@ describe('GatherRunner', function() {
],
};

return GatherRunner.afterPass({url, driver, passConfig}, {TestGatherer: []}).then(vals => {
assert.equal(calledDevtoolsLogCollect, true);
assert.strictEqual(vals.devtoolsLog[0], fakeDevtoolsMessage);
});
return GatherRunner.afterPass({url, driver, passConfig}, {TestGatherer: []})
.then(([passData]) => {
assert.equal(calledDevtoolsLogCollect, true);
assert.strictEqual(passData.devtoolsLog[0], fakeDevtoolsMessage);
});
});

it('does as many passes as are required', () => {
Expand Down Expand Up @@ -625,58 +627,89 @@ describe('GatherRunner', function() {
});

describe('#getPageLoadError', () => {
it('passes when the page is loaded', () => {
it('passes when the page is loaded', async () => {
const url = 'http://the-page.com';
const mainRecord = new NetworkRequest();
mainRecord.url = url;
assert.ok(!GatherRunner.getPageLoadError(url, [mainRecord]));
assert.ok(!await GatherRunner.getPageLoadError(fakeDriver, url, [mainRecord]));
});

it('passes when the page is loaded, ignoring any fragment', () => {
it('passes when the page is loaded, ignoring any fragment', async () => {
const url = 'http://example.com/#/page/list';
const mainRecord = new NetworkRequest();
mainRecord.url = 'http://example.com';
assert.ok(!GatherRunner.getPageLoadError(url, [mainRecord]));
assert.ok(!await GatherRunner.getPageLoadError(fakeDriver, url, [mainRecord]));
});

it('fails when page fails to load', () => {
it('fails when page fails to load', async () => {
const url = 'http://the-page.com';
const mainRecord = new NetworkRequest();
mainRecord.url = url;
mainRecord.failed = true;
mainRecord.localizedFailDescription = 'foobar';
const error = GatherRunner.getPageLoadError(url, [mainRecord]);
const error = await GatherRunner.getPageLoadError(fakeDriver, url, [mainRecord]);
assert.equal(error.message, 'FAILED_DOCUMENT_REQUEST');
assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
});

it('fails when page times out', () => {
it('fails when page times out', async () => {
const url = 'http://the-page.com';
const records = [];
const error = GatherRunner.getPageLoadError(url, records);
const error = await GatherRunner.getPageLoadError(fakeDriver, url, records);
assert.equal(error.message, 'NO_DOCUMENT_REQUEST');
assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
});

it('fails when page returns with a 404', () => {
it('fails when page returns with a 404', async () => {
const url = 'http://the-page.com';
const mainRecord = new NetworkRequest();
mainRecord.url = url;
mainRecord.statusCode = 404;
const error = GatherRunner.getPageLoadError(url, [mainRecord]);
const error = await GatherRunner.getPageLoadError(fakeDriver, url, [mainRecord]);
assert.equal(error.message, 'ERRORED_DOCUMENT_REQUEST');
assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
});

it('fails when page returns with a 500', () => {
it('fails when page returns with a 500', async () => {
const url = 'http://the-page.com';
const mainRecord = new NetworkRequest();
mainRecord.url = url;
mainRecord.statusCode = 500;
const error = GatherRunner.getPageLoadError(url, [mainRecord]);
const error = await GatherRunner.getPageLoadError(fakeDriver, url, [mainRecord]);
assert.equal(error.message, 'ERRORED_DOCUMENT_REQUEST');
assert.ok(/^Lighthouse was unable to reliably load/.test(error.friendlyMessage));
});

it('fails when page is insecure', async () => {
const mockDriver = Object.assign({}, fakeDriver, {
async getSecurityState() {
return {
explanations: [
{
description: 'reason 1.',
securityState: 'insecure',
},
{
description: 'blah.',
securityState: 'info',
},
{
description: 'reason 2.',
securityState: 'insecure',
},
],
securityState: 'insecure',
};
},
});
const url = 'http://the-page.com';
const mainRecord = new NetworkRequest();
mainRecord.url = url;
const error = await GatherRunner.getPageLoadError(mockDriver, url, [mainRecord]);
assert.equal(error.message, 'INSECURE_DOCUMENT_REQUEST');
/* eslint-disable-next-line max-len */
assert.equal(error.friendlyMessage, 'The URL you have provided does not have a valid security certificate. Insecure: reason 1. reason 2.');
});
});

describe('artifact collection', () => {
Expand Down