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

Open in Lighthouse Viewer button #1179

Merged
merged 10 commits into from
Jan 13, 2017
Merged

Open in Lighthouse Viewer button #1179

merged 10 commits into from
Jan 13, 2017

Conversation

ebidel
Copy link
Contributor

@ebidel ebidel commented Dec 19, 2016

R: @paulirish @patrickhulce @brendankenny

This adds a button to the nav. When clicked, the local report opens the viewer ( http://googlechrome.github.io/lighthouse/viewer/) in a new tab and postMessage's the report json to it. The online viewer has a message listener that responds and constructs the page. This functionality will encourage folks to use the online viewer (where we have save as gist, shareable links, export functionality, etc.) It may also be a solution for the DevtTools integration.

On http://googlechrome.github.io/lighthouse/viewer/, the "view online" button will be disabled:

screen shot 2016-12-19 at 12 55 57 pm

On html reports generated by the CLI or the extension, the button will be enabled:
screen shot 2016-12-19 at 12 56 06 pm

Security concerns were addressed in #1188 by removing {{{}}} usage of user-provided content.

@ebidel ebidel added the viewer label Dec 19, 2016
Copy link
Member

@brendankenny brendankenny left a comment

Choose a reason for hiding this comment

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

this makes me nervous :) especially since we can't really control the origin of the report. An XSS could conceivably let someone create gists in your account if you've already given permission, though that would probably rely on a handlebars exploit or one of our formatter helpers being stupid.

I don't really want to rely on us never writing stupid code, though. Should we write a lighthouse results object validator that strips out everything that doesn't match it?

'lighthouse-report.js was not inlined');
assert.ok(html.includes('.report-body {'), 'report.css was not inlined');
assert.ok(!html.includes('"lighthouseVersion'), 'lhresults were not escaped');
assert.ok(html.includes('window.lhresults = '), 'report results were inlined');
Copy link
Member

Choose a reason for hiding this comment

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

do you want to test more than this? (though you do test for lighthouseVersion, below?) In the past we had a failure for a while where window.lhresults = undefined; was passing the test

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll check that there's an object on the page, but I don't think we should get in the business of validating the object as it's embedded on the page.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

*/
listenForMessages() {
window.addEventListener('message', e => {
if (e.data.lhresults) {
Copy link
Member

Choose a reason for hiding this comment

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

not a huge increase in security, but can we check the message origin/source against the opener?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

});

// If the page was opened as a popup, tell the opening window we're ready.
if (self.opener && !self.closed) {
Copy link
Member

Choose a reason for hiding this comment

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

self.closed? or checking the opener as closed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

opener. done.


// If the page was opened as a popup, tell the opening window we're ready.
if (self.opener && !self.closed) {
self.opener.postMessage({opened: true}, '*');
Copy link
Member

Choose a reason for hiding this comment

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

again, doesn't do much, but don't we know the origin here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately, you can't access opener.origin if the opener is cross domain.

screen shot 2016-12-20 at 1 43 18 pm

@ebidel
Copy link
Contributor Author

ebidel commented Dec 20, 2016

PTAL.

An XSS could conceivably let someone create gists in your account if you've already given permission, though that would probably rely on a handlebars exploit or one of our formatter helpers being stupid.

If you can exploit the viewer like this, it's already possible since we allow drag and drop of a user file into the app. This additional also gores through our ReportGenerator.generateHTML, which goes through Handlebars under the hood. In most places we use {{ }} in the HTML template to prevent script injection. That goes through http://handlebarsjs.com/reference.html#base-escapeExpression by default, so we should be good.

The places where we use{{{ }}} to render user content should be replaced by something safer. I think we should do that in a followup PR because that's tangential to these changes and a broader change we need to make. I'd also like to write tests one to handle both cases.

Update: filed #1188 to remove {{{ }}} usage.
Update: #1188 is fixed in #1226.

@ebidel
Copy link
Contributor Author

ebidel commented Dec 21, 2016

@brendankenny rebased and passing.

@ebidel ebidel force-pushed the postmessage branch 3 times, most recently from 27920af to 352149d Compare January 9, 2017 23:17
@ebidel
Copy link
Contributor Author

ebidel commented Jan 10, 2017

Pingy ping. This has been open for 22 days.

I think we should start with this before upstreaming viewer code into core (future PR). This PR is useful standalone.

Copy link
Member

@paulirish paulirish left a comment

Choose a reason for hiding this comment

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

From a UX pov I have been thinking of something slightly different:

  1. User runs lighthouse
  2. They view the HTML report (locally)
  3. They want to save or share this particular report
  4. They click a "generate link" kind of button.
  5. This will open a new tab with the production report-viewer
  6. (User authenticates if its their first time)
  7. After communicating progress, the URL of the page is updated to now updated to the new permalink
  8. A "Copy link" link is available to automate adding to clipboard.

it's a bit of a change, so how does that UX sound to you?

{{#if script }}
<script>
window.lhresults = {{{lhresults}}};

Copy link
Member

Choose a reason for hiding this comment

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

let's put the lhresults in a separate script tag from the {{{script}}}.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

@brendankenny
Copy link
Member

is there a reason to stick with the postMessage solution if we're going to pull in the save to gist mechanism from Viewer into the report? Then it just becomes save button -> upload -> update link to permalink

?

@paulirish
Copy link
Member

@brendankenny i think there's two parts to that

  1. that wont work on file://. Undetermined if that's a problem we care about
  2. we need to validate the auth flow is possible & fine from a (arbitrary) localhost post

@ebidel
Copy link
Contributor Author

ebidel commented Jan 10, 2017

@paulirish I think this will capture that entire workflow. The only thing that we'd have to change is:

4.) is "open in viewer" instead of "generate link"...but we could do a thing where if you click the share button inside a local report, it goes through your flow.
6.) this is manual right now but we could auto-detect 4. and automatically auth the user (if they're not already).

@brendankenny postMessage allows us to view json results from other locations like the devtools.

* Initializes of a `message` listener to respond to postMessage events.
*/
listenForMessages() {
window.addEventListener('message', e => {
Copy link
Member

Choose a reason for hiding this comment

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

remove listener after a report loaded?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can see cases where we'd want the same page to update itself again. For example, when the DT opens the tab, it could re-postMessage() to the existing open tab.

@ebidel
Copy link
Contributor Author

ebidel commented Jan 11, 2017

Rebased

Copy link
Member

@brendankenny brendankenny left a comment

Choose a reason for hiding this comment

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

one more XSS thought, but except for better escaping this approach is looking good. It would be great to get another reviewer looking at safety before landing, though :)

@@ -19,9 +19,34 @@

'use strict';

function sendJSONReport() {
Copy link
Member

Choose a reason for hiding this comment

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

add a function description comment?


{{#if scripts }}
<script>
window.lhresults = {{{lhresults}}};
Copy link
Member

Choose a reason for hiding this comment

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

would it be totally crazy to do even more defense in depth here by putting lhresults in an html element instead of in a <script>?

Really we should be doing more to escape our JS, but one end run around this is to do double stash {{lhresults}} in a (display: none) div, so we get HTML entity escaping, then get the actual results when needed by JSON.parse(lhresultsDiv.textContent) (see rule 1 and the summary section for more details)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, so to be clear, these <script>s (and therefore the {{{}}}) never see user-provided input.

The flow is:
User clicks "open in viewer", the local page opens the online viewer (a page with different markup) and sends the window.lhresults JSON to it. That page's message listener does the replaceReportHTML. That method then goes through our normal Handlebars pipeline to generate the inner page content.

Copy link
Member

@brendankenny brendankenny Jan 12, 2017

Choose a reason for hiding this comment

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

right, but lhresults does see user input (which is why we have to escape any </script> in it).

This suggestion is actually independent of the postMessage part of this PR, and just about (safely) embedding lhresults in the page again. Basically their advice is that if you're going to put user generated data in a page, an easy way to keep it safe is doing html escape (which the double handlebars stash does) inside an HTML element. If you want to embed the user data in a JS var literal instead there's a lot more work to do (and it doesn't appear that handlebars offers that escaping by default).

This would also let us drop our own </script> escaping and it would leave us in a lot safer spot wrt exploits of the postMessage system, however unlikely those are, and the change doesn't seem semantically that bad.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Really we should be doing more to escape our JS, but one end run around this is to do double stash {{lhresults}} in a (display: none) div, so we get HTML entity escaping, then get the actual results when needed by JSON.parse(lhresultsDiv.textContent) (see rule 1 and the summary section for more details)

I'm not sure this does anything different other than adding an additional parse. Handlebars, #1188, and #996 already force entity escaping everywhere that's needed.

The lhresults object is also constructed and sanitized by us. It's not pure user-input. Parts of the object come from user-space but we only allow markdown (two tags at that). If the user provides HTML tags, they'll end up encoded.

This suggestion is actually independent of the postMessage part of this PR, and just about (safely) embedding lhresults in the page again.

The thing we're trying to prevent is a malicious actor creating gists in a user's account. That all happens online. Locally, I'm not sure what there is to worry about? A malicious actor would have to create a page such that running LH over it properly escapes the embedded JSON (which we prevent), alters lhresults to their advantage, the user clicks "share in viewer", the rev'd lhresults is able to escape the online viewer's {{}}, passes additional JSON.parse() and DOM construction calls, then has code to automatically call createGist() (we don't provide a way to call this; creating a gist is behind user gesture atm). The also needs to already have an oauth token.

If something were to ahppen, our oauth tokens are limited scoped. They can only create gists in the users account. They're also secret by default (for extra feel goods) and can be revoked by the user at any time.

The theme of this PR is to encourage users to share reports. Given the current low volume of sharers, let's land this so we can grow some us LH users!

Copy link
Member

Choose a reason for hiding this comment

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

The thing we're trying to prevent is a malicious actor creating gists in a user's account

Ideally we should prevent them from doing anything, of course. It's better that way but it also helps us down the line as we add new features, things get more complicated, etc. lhresults is still used in the extension's report, and while it's stripped out in replaceReportHTML() right now, it's possible we'll want to use it at some point in the future.

I'm not sure this does anything different other than adding an additional parse.

It is an additional parse, but it means the only cost is an additional parse. Meanwhile everything is escaped and no matter the user generated content it won't be able to break out. If we assign the object in js we're doing an eval with an optional innerHTML if it can close the element, with all the danger that entails. JS escaping is fairly complicated; this seems like an easy way to make sure we have it right (at least as a defense-in-depth thing with the rest of our efforts).

Handlebars, #1188, and #996 already force entity escaping everywhere that's needed.

The various other template entries are protected, but {{{lhresults}}} is raw and only protected by escaping the closing </script> tag from #996. Unless we're very careful it's possible someone will be able to do the double escape trick to get those back (or try one of the various other tricks in that doc) with our current system or in the future. Handlebars and the markdown change don't touch lhresults since it's using the triple stash, and we're adding new user content all the time (e.g. the new CSS usage audit, which adds verbatim quotes from stylesheets in its extendedInfo).

Given the current low volume of sharers, let's land this so we can grow some us LH users!

Totally agree. The main thing is that this is a fairly trivial change with no real overhead (semantically or performance-wise). The escaping that needs to happen is small in scope and already handled by Handlebars. It keeps us to the guarantee that the only things in {{{}}} is content completely generated by LH itself.

It's really just

<div id="lhresults">{{lhresults}}</div>
{{#if scripts }}
<script>
  // Use escaped results object to protect against XSS from result details.
  window.lhresults = JSON.parse(document.querySelector('#lhresults').textContent);
</script>
{{/if}}

(with display: none in the css) and everything else acts as it does now with no other changes.

Copy link
Member

Choose a reason for hiding this comment

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

Really not trying to be argumentative, this just seems like an easy way to secure us for the future :)

Copy link
Member

Choose a reason for hiding this comment

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

I also prefer putting the lhresults in a non-script element. How about <template>?
TBH when i was first trying out the lhresults, it created some html elements from various descriptions. I think those things are addressed now, but i feel safer not throwing fairly arbitrary text into a script tag.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I kept the _escapeScriptTags. Defense in depth-n-stuff.

Copy link
Member

@brendankenny brendankenny left a comment

Choose a reason for hiding this comment

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

Nice!
📜
🔽
✉️ ➡️ ☁️

@brendankenny brendankenny merged commit ec97c35 into master Jan 13, 2017
@brendankenny brendankenny deleted the postmessage branch January 13, 2017 05:13
andrewrota pushed a commit to andrewrota/lighthouse that referenced this pull request Jan 13, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants