From 81e429eb143ae99e25af80462570d8f77653a026 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Fri, 2 Dec 2016 14:21:07 +0000 Subject: [PATCH] Fix e2e attachment download by using iframes. (#562) * Render attachments inside iframes. * Fix up the image and video views * Fix m.audio * Comments, and only use the cross domain renderer if the attachment is encrypted * Fix whitespace * Don't decrypt file attachments immediately * Use https://usercontent.riot.im/v1.html by default * typos * Put the config in the React context. Use it in MFileBody to configure the cross origin renderer URL. * Call it appConfig in the context * Return the promises so they don't get dropped --- src/components/structures/MatrixChat.js | 10 + src/components/views/messages/MAudioBody.js | 12 +- src/components/views/messages/MFileBody.js | 280 +++++++++++++++----- src/components/views/messages/MImageBody.js | 16 +- src/components/views/messages/MVideoBody.js | 16 +- src/utils/DecryptFile.js | 4 +- 6 files changed, 253 insertions(+), 85 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 2a31850f68..ff5a44e016 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -66,10 +66,20 @@ module.exports = React.createClass({ defaultDeviceDisplayName: React.PropTypes.string, }, + childContextTypes: { + appConfig: React.PropTypes.object, + }, + AuxPanel: { RoomSettings: "room_settings", }, + getChildContext: function() { + return { + appConfig: this.props.config, + } + }, + getInitialState: function() { var s = { loading: true, diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index 393bf549ae..7e338e8466 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -21,7 +21,7 @@ import MFileBody from './MFileBody'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; -import { decryptFile } from '../../../utils/DecryptFile'; +import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; export default class MAudioBody extends React.Component { constructor(props) { @@ -29,6 +29,7 @@ export default class MAudioBody extends React.Component { this.state = { playing: false, decryptedUrl: null, + decryptedBlob: null, error: null, } } @@ -50,9 +51,14 @@ export default class MAudioBody extends React.Component { componentDidMount() { var content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { - decryptFile(content.file).done((url) => { + var decryptedBlob; + decryptFile(content.file).then(function(blob) { + decryptedBlob = blob; + return readBlobAsDataUri(decryptedBlob); + }).done((url) => { this.setState({ decryptedUrl: url, + decryptedBlob: decryptedBlob, }); }, (err) => { console.warn("Unable to decrypt attachment: ", err); @@ -93,7 +99,7 @@ export default class MAudioBody extends React.Component { return ( ); } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index c7c0881cbb..4f5ca2d3be 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -63,15 +63,137 @@ function updateTintedDownloadImage() { Tinter.registerTintable(updateTintedDownloadImage); +// User supplied content can contain scripts, we have to be careful that +// we don't accidentally run those script within the same origin as the +// client. Otherwise those scripts written by remote users can read +// the access token and end-to-end keys that are in local storage. +// +// For attachments downloaded directly from the homeserver we can use +// Content-Security-Policy headers to disable script execution. +// +// But attachments with end-to-end encryption are more difficult to handle. +// We need to decrypt the attachment on the client and then display it. +// To display the attachment we need to turn the decrypted bytes into a URL. +// +// There are two ways to turn bytes into URLs, data URL and blob URLs. +// Data URLs aren't suitable for downloading a file because Chrome has a +// 2MB limit on the size of URLs that can be viewed in the browser or +// downloaded. This limit does not seem to apply when the url is used as +// the source attribute of an image tag. +// +// Blob URLs are generated using window.URL.createObjectURL and unforuntately +// for our purposes they inherit the origin of the page that created them. +// This means that any scripts that run when the URL is viewed will be able +// to access local storage. +// +// The easiest solution is to host the code that generates the blob URL on +// a different domain to the client. +// Another possibility is to generate the blob URL within a sandboxed iframe. +// The downside of using a second domain is that it complicates hosting, +// the downside of using a sandboxed iframe is that the browers are overly +// restrictive in what you are allowed to do with the generated URL. +// +// For now given how unusable the blobs generated in sandboxed iframes are we +// default to using a renderer hosted on "usercontent.riot.im". This is +// overridable so that people running their own version of the client can +// choose a different renderer. +// +// To that end the first version of the blob generation will be the following +// html: +// +// +// +// This waits to receive a message event sent using the window.postMessage API. +// When it receives the event it evals a javascript function in data.code and +// runs the function passing the event as an argument. +// +// In particular it means that the rendering function can be written as a +// ordinary javascript function which then is turned into a string using +// toString(). +// +const DEFAULT_CROSS_ORIGIN_RENDERER = "https://usercontent.riot.im/v1.html"; + +/** + * Render the attachment inside the iframe. + * We can't use imported libraries here so this has to be vanilla JS. + */ +function remoteRender(event) { + const data = event.data; + + const img = document.createElement("img"); + img.id = "img"; + img.src = data.imgSrc; + + const a = document.createElement("a"); + a.id = "a"; + a.rel = data.rel; + a.target = data.target; + a.download = data.download; + a.style = data.style; + a.href = window.URL.createObjectURL(data.blob); + a.appendChild(img); + a.appendChild(document.createTextNode(data.textContent)); + + const body = document.body; + // Don't display scrollbars if the link takes more than one line + // to display. + body.style = "margin: 0px; overflow: hidden"; + body.appendChild(a); +} + +/** + * Update the tint inside the iframe. + * We can't use imported libraries here so this has to be vanilla JS. + */ +function remoteSetTint(event) { + const data = event.data; + + const img = document.getElementById("img"); + img.src = data.imgSrc; + img.style = data.imgStyle; + + const a = document.getElementById("a"); + a.style = data.style; +} + + +/** + * Get the current CSS style for a DOMElement. + * @param {HTMLElement} element The element to get the current style of. + * @return {string} The CSS style encoded as a string. + */ +function computedStyle(element) { + if (!element) { + return ""; + } + const style = window.getComputedStyle(element, null); + var cssText = style.cssText; + if (cssText == "") { + // Firefox doesn't implement ".cssText" for computed styles. + // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 + for (var i = 0; i < style.length; i++) { + cssText += style[i] + ":"; + cssText += style.getPropertyValue(style[i]) + ";"; + } + } + return cssText; +} + module.exports = React.createClass({ displayName: 'MFileBody', getInitialState: function() { return { - decryptedUrl: (this.props.decryptedUrl ? this.props.decryptedUrl : null), + decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null), }; }, + contextTypes: { + appConfig: React.PropTypes.object, + }, + /** * Extracts a human readable label for the file attachment to use as * link text. @@ -102,11 +224,7 @@ module.exports = React.createClass({ _getContentUrl: function() { const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { - return this.state.decryptedUrl; - } else { - return MatrixClientPeg.get().mxcUrlToHttp(content.url); - } + return MatrixClientPeg.get().mxcUrlToHttp(content.url); }, componentDidMount: function() { @@ -127,90 +245,108 @@ module.exports = React.createClass({ if (this.refs.downloadImage) { this.refs.downloadImage.src = tintedDownloadImageURL; } + if (this.refs.iframe) { + // If the attachment is encrypted then the download image + // will be inside the iframe so we wont be able to update + // it directly. + this.refs.iframe.contentWindow.postMessage({ + code: remoteSetTint.toString(), + imgSrc: tintedDownloadImageURL, + style: computedStyle(this.refs.dummyLink), + }, "*"); + } }, render: function() { const content = this.props.mxEvent.getContent(); - const text = this.presentableTextForFile(content); + const isEncrypted = content.file !== undefined; + const fileName = content.body && content.body.length > 0 ? content.body : "Attachment"; + const contentUrl = this._getContentUrl(); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - if (content.file !== undefined && this.state.decryptedUrl === null) { + if (isEncrypted) { + if (this.state.decryptedBlob === null) { + // Need to decrypt the attachment + // Wait for the user to click on the link before downloading + // and decrypting the attachment. + var decrypting = false; + const decrypt = () => { + if (decrypting) { + return false; + } + decrypting = true; + decryptFile(content.file).then((blob) => { + this.setState({ + decryptedBlob: blob, + }); + }).catch((err) => { + console.warn("Unable to decrypt attachment: ", err) + Modal.createDialog(ErrorDialog, { + description: "Error decrypting attachment" + }); + }).finally(() => { + decrypting = false; + return; + }); + }; - var decrypting = false; - const decrypt = () => { - if (decrypting) { - return false; - } - decrypting = true; - decryptFile(content.file).then((url) => { - this.setState({ - decryptedUrl: url, - }); - }).catch((err) => { - console.warn("Unable to decrypt attachment: ", err) - // Set a placeholder image when we can't decrypt the image - Modal.createDialog(ErrorDialog, { - description: "Error decrypting attachment" - }); - }).finally(function() { - decrypting = false; - }).done(); - return false; + return ( + +
+ + Decrypt {text} + +
+
+ ); + } + + // When the iframe loads we tell it to render a download link + const onIframeLoad = (ev) => { + ev.target.contentWindow.postMessage({ + code: remoteRender.toString(), + imgSrc: tintedDownloadImageURL, + style: computedStyle(this.refs.dummyLink), + blob: this.state.decryptedBlob, + // Set a download attribute for encrypted files so that the file + // will have the correct name when the user tries to download it. + // We can't provide a Content-Disposition header like we would for HTTP. + download: fileName, + target: "_blank", + textContent: "Download " + text, + }, "*"); }; - // Need to decrypt the attachment - // The attachment is decrypted in componentDidMount. - // For now add an img tag with a spinner. + // If the attachment is encryped then put the link inside an iframe. + let renderer_url = DEFAULT_CROSS_ORIGIN_RENDERER; + if (this.context.appConfig && this.context.appConfig.cross_origin_renderer_url) { + renderer_url = this.context.appConfig.cross_origin_renderer_url; + } return ( - +
- - Decrypt {text} - +
+ {/* + * Add dummy copy of the "a" tag + * We'll use it to learn how the download link + * would have been styled if it was rendered inline. + */} + +
+