@@ -63,15 +63,137 @@ function updateTintedDownloadImage() {
6363
6464Tinter . registerTintable ( updateTintedDownloadImage ) ;
6565
66+ // User supplied content can contain scripts, we have to be careful that
67+ // we don't accidentally run those script within the same origin as the
68+ // client. Otherwise those scripts written by remote users can read
69+ // the access token and end-to-end keys that are in local storage.
70+ //
71+ // For attachments downloaded directly from the homeserver we can use
72+ // Content-Security-Policy headers to disable script execution.
73+ //
74+ // But attachments with end-to-end encryption are more difficult to handle.
75+ // We need to decrypt the attachment on the client and then display it.
76+ // To display the attachment we need to turn the decrypted bytes into a URL.
77+ //
78+ // There are two ways to turn bytes into URLs, data URL and blob URLs.
79+ // Data URLs aren't suitable for downloading a file because Chrome has a
80+ // 2MB limit on the size of URLs that can be viewed in the browser or
81+ // downloaded. This limit does not seem to apply when the url is used as
82+ // the source attribute of an image tag.
83+ //
84+ // Blob URLs are generated using window.URL.createObjectURL and unforuntately
85+ // for our purposes they inherit the origin of the page that created them.
86+ // This means that any scripts that run when the URL is viewed will be able
87+ // to access local storage.
88+ //
89+ // The easiest solution is to host the code that generates the blob URL on
90+ // a different domain to the client.
91+ // Another possibility is to generate the blob URL within a sandboxed iframe.
92+ // The downside of using a second domain is that it complicates hosting,
93+ // the downside of using a sandboxed iframe is that the browers are overly
94+ // restrictive in what you are allowed to do with the generated URL.
95+ //
96+ // For now given how unusable the blobs generated in sandboxed iframes are we
97+ // default to using a renderer hosted on "usercontent.riot.im". This is
98+ // overridable so that people running their own version of the client can
99+ // choose a different renderer.
100+ //
101+ // To that end the first version of the blob generation will be the following
102+ // html:
103+ //
104+ // <html><head><script>
105+ // window.onmessage=function(e){eval("("+e.data.code+")")(e)}
106+ // </script></head><body></body></html>
107+ //
108+ // This waits to receive a message event sent using the window.postMessage API.
109+ // When it receives the event it evals a javascript function in data.code and
110+ // runs the function passing the event as an argument.
111+ //
112+ // In particular it means that the rendering function can be written as a
113+ // ordinary javascript function which then is turned into a string using
114+ // toString().
115+ //
116+ const DEFAULT_CROSS_ORIGIN_RENDERER = "https://usercontent.riot.im/v1.html" ;
117+
118+ /**
119+ * Render the attachment inside the iframe.
120+ * We can't use imported libraries here so this has to be vanilla JS.
121+ */
122+ function remoteRender ( event ) {
123+ const data = event . data ;
124+
125+ const img = document . createElement ( "img" ) ;
126+ img . id = "img" ;
127+ img . src = data . imgSrc ;
128+
129+ const a = document . createElement ( "a" ) ;
130+ a . id = "a" ;
131+ a . rel = data . rel ;
132+ a . target = data . target ;
133+ a . download = data . download ;
134+ a . style = data . style ;
135+ a . href = window . URL . createObjectURL ( data . blob ) ;
136+ a . appendChild ( img ) ;
137+ a . appendChild ( document . createTextNode ( data . textContent ) ) ;
138+
139+ const body = document . body ;
140+ // Don't display scrollbars if the link takes more than one line
141+ // to display.
142+ body . style = "margin: 0px; overflow: hidden" ;
143+ body . appendChild ( a ) ;
144+ }
145+
146+ /**
147+ * Update the tint inside the iframe.
148+ * We can't use imported libraries here so this has to be vanilla JS.
149+ */
150+ function remoteSetTint ( event ) {
151+ const data = event . data ;
152+
153+ const img = document . getElementById ( "img" ) ;
154+ img . src = data . imgSrc ;
155+ img . style = data . imgStyle ;
156+
157+ const a = document . getElementById ( "a" ) ;
158+ a . style = data . style ;
159+ }
160+
161+
162+ /**
163+ * Get the current CSS style for a DOMElement.
164+ * @param {HTMLElement } element The element to get the current style of.
165+ * @return {string } The CSS style encoded as a string.
166+ */
167+ function computedStyle ( element ) {
168+ if ( ! element ) {
169+ return "" ;
170+ }
171+ const style = window . getComputedStyle ( element , null ) ;
172+ var cssText = style . cssText ;
173+ if ( cssText == "" ) {
174+ // Firefox doesn't implement ".cssText" for computed styles.
175+ // https://bugzilla.mozilla.org/show_bug.cgi?id=137687
176+ for ( var i = 0 ; i < style . length ; i ++ ) {
177+ cssText += style [ i ] + ":" ;
178+ cssText += style . getPropertyValue ( style [ i ] ) + ";" ;
179+ }
180+ }
181+ return cssText ;
182+ }
183+
66184module . exports = React . createClass ( {
67185 displayName : 'MFileBody' ,
68186
69187 getInitialState : function ( ) {
70188 return {
71- decryptedUrl : ( this . props . decryptedUrl ? this . props . decryptedUrl : null ) ,
189+ decryptedBlob : ( this . props . decryptedBlob ? this . props . decryptedBlob : null ) ,
72190 } ;
73191 } ,
74192
193+ contextTypes : {
194+ appConfig : React . PropTypes . object ,
195+ } ,
196+
75197 /**
76198 * Extracts a human readable label for the file attachment to use as
77199 * link text.
@@ -102,11 +224,7 @@ module.exports = React.createClass({
102224
103225 _getContentUrl : function ( ) {
104226 const content = this . props . mxEvent . getContent ( ) ;
105- if ( content . file !== undefined ) {
106- return this . state . decryptedUrl ;
107- } else {
108- return MatrixClientPeg . get ( ) . mxcUrlToHttp ( content . url ) ;
109- }
227+ return MatrixClientPeg . get ( ) . mxcUrlToHttp ( content . url ) ;
110228 } ,
111229
112230 componentDidMount : function ( ) {
@@ -127,90 +245,108 @@ module.exports = React.createClass({
127245 if ( this . refs . downloadImage ) {
128246 this . refs . downloadImage . src = tintedDownloadImageURL ;
129247 }
248+ if ( this . refs . iframe ) {
249+ // If the attachment is encrypted then the download image
250+ // will be inside the iframe so we wont be able to update
251+ // it directly.
252+ this . refs . iframe . contentWindow . postMessage ( {
253+ code : remoteSetTint . toString ( ) ,
254+ imgSrc : tintedDownloadImageURL ,
255+ style : computedStyle ( this . refs . dummyLink ) ,
256+ } , "*" ) ;
257+ }
130258 } ,
131259
132260 render : function ( ) {
133261 const content = this . props . mxEvent . getContent ( ) ;
134-
135262 const text = this . presentableTextForFile ( content ) ;
263+ const isEncrypted = content . file !== undefined ;
264+ const fileName = content . body && content . body . length > 0 ? content . body : "Attachment" ;
265+ const contentUrl = this . _getContentUrl ( ) ;
136266 const ErrorDialog = sdk . getComponent ( "dialogs.ErrorDialog" ) ;
137267
138- if ( content . file !== undefined && this . state . decryptedUrl === null ) {
139-
140- var decrypting = false ;
141- const decrypt = ( ) => {
142- if ( decrypting ) {
143- return false ;
144- }
145- decrypting = true ;
146- decryptFile ( content . file ) . then ( ( url ) => {
147- this . setState ( {
148- decryptedUrl : url ,
149- } ) ;
150- } ) . catch ( ( err ) => {
151- console . warn ( "Unable to decrypt attachment: " , err )
152- // Set a placeholder image when we can't decrypt the image
153- Modal . createDialog ( ErrorDialog , {
154- description : "Error decrypting attachment"
268+ if ( isEncrypted ) {
269+ if ( this . state . decryptedBlob === null ) {
270+ // Need to decrypt the attachment
271+ // Wait for the user to click on the link before downloading
272+ // and decrypting the attachment.
273+ var decrypting = false ;
274+ const decrypt = ( ) => {
275+ if ( decrypting ) {
276+ return false ;
277+ }
278+ decrypting = true ;
279+ decryptFile ( content . file ) . then ( ( blob ) => {
280+ this . setState ( {
281+ decryptedBlob : blob ,
282+ } ) ;
283+ } ) . catch ( ( err ) => {
284+ console . warn ( "Unable to decrypt attachment: " , err )
285+ Modal . createDialog ( ErrorDialog , {
286+ description : "Error decrypting attachment"
287+ } ) ;
288+ } ) . finally ( ( ) => {
289+ decrypting = false ;
290+ return ;
155291 } ) ;
156- } ) . finally ( function ( ) {
157- decrypting = false ;
158- } ) . done ( ) ;
159- return false ;
292+ } ;
293+
294+ return (
295+ < span className = "mx_MFileBody" ref = "body" >
296+ < div className = "mx_MImageBody_download" >
297+ < a href = "javascript:void(0)" onClick = { decrypt } >
298+ Decrypt { text }
299+ </ a >
300+ </ div >
301+ </ span >
302+ ) ;
303+ }
304+
305+ // When the iframe loads we tell it to render a download link
306+ const onIframeLoad = ( ev ) => {
307+ ev . target . contentWindow . postMessage ( {
308+ code : remoteRender . toString ( ) ,
309+ imgSrc : tintedDownloadImageURL ,
310+ style : computedStyle ( this . refs . dummyLink ) ,
311+ blob : this . state . decryptedBlob ,
312+ // Set a download attribute for encrypted files so that the file
313+ // will have the correct name when the user tries to download it.
314+ // We can't provide a Content-Disposition header like we would for HTTP.
315+ download : fileName ,
316+ target : "_blank" ,
317+ textContent : "Download " + text ,
318+ } , "*" ) ;
160319 } ;
161320
162- // Need to decrypt the attachment
163- // The attachment is decrypted in componentDidMount.
164- // For now add an img tag with a spinner.
321+ // If the attachment is encryped then put the link inside an iframe.
322+ let renderer_url = DEFAULT_CROSS_ORIGIN_RENDERER ;
323+ if ( this . context . appConfig && this . context . appConfig . cross_origin_renderer_url ) {
324+ renderer_url = this . context . appConfig . cross_origin_renderer_url ;
325+ }
165326 return (
166- < span className = "mx_MFileBody" ref = "body" >
327+ < span className = "mx_MFileBody" >
167328 < div className = "mx_MImageBody_download" >
168- < a href = "javascript:void(0)" onClick = { decrypt } >
169- Decrypt { text }
170- </ a >
329+ < div style = { { display : "none" } } >
330+ { /*
331+ * Add dummy copy of the "a" tag
332+ * We'll use it to learn how the download link
333+ * would have been styled if it was rendered inline.
334+ */ }
335+ < a ref = "dummyLink" />
336+ </ div >
337+ < iframe src = { renderer_url } onLoad = { onIframeLoad } ref = "iframe" />
171338 </ div >
172339 </ span >
173340 ) ;
174- }
175-
176- const contentUrl = this . _getContentUrl ( ) ;
177-
178- const fileName = content . body && content . body . length > 0 ? content . body : "Attachment" ;
179-
180- var downloadAttr = undefined ;
181- if ( this . state . decryptedUrl ) {
182- // If the file is encrypted then we MUST download it rather than displaying it
183- // because Firefox is vunerable to XSS attacks in data:// URLs
184- // and all browsers are vunerable to XSS attacks in blob: URLs
185- // created with window.URL.createObjectURL
186- // See https://bugzilla.mozilla.org/show_bug.cgi?id=255107
187- // See https://w3c.github.io/FileAPI/#originOfBlobURL
188- //
189- // This is not a problem for unencrypted links because they are
190- // either fetched from a different domain so are safe because of
191- // the same-origin policy or they are fetch from the same domain,
192- // in which case we trust that the homeserver will set a
193- // Content-Security-Policy that disables script execution.
194- // It is reasonable to trust the homeserver in that case since
195- // it is the same domain that controls this javascript.
196- //
197- // We can't apply the same workaround for encrypted files because
198- // we can't supply HTTP headers when the user clicks on a blob:
199- // or data:// uri.
200- //
201- // We should probably provide a download attribute anyway so that
202- // the file will have the correct name when the user tries to
203- // download it. We can't provide a Content-Disposition header
204- // like we would for HTTP.
205- downloadAttr = fileName ;
206- }
207-
208- if ( contentUrl ) {
341+ } else if ( contentUrl ) {
342+ // If the attachment is not encrypted then we check whether we
343+ // are being displayed in the room timeline or in a list of
344+ // files in the right hand side of the screen.
209345 if ( this . props . tileShape === "file_grid" ) {
210346 return (
211347 < span className = "mx_MFileBody" >
212348 < div className = "mx_MImageBody_download" >
213- < a className = "mx_ImageBody_downloadLink" href = { contentUrl } target = "_blank" rel = "noopener" download = { downloadAttr } >
349+ < a className = "mx_ImageBody_downloadLink" href = { contentUrl } target = "_blank" >
214350 { fileName }
215351 </ a >
216352 < div className = "mx_MImageBody_size" >
@@ -224,7 +360,7 @@ module.exports = React.createClass({
224360 return (
225361 < span className = "mx_MFileBody" >
226362 < div className = "mx_MImageBody_download" >
227- < a href = { contentUrl } target = "_blank" rel = "noopener" download = { downloadAttr } >
363+ < a href = { contentUrl } target = "_blank" rel = "noopener" >
228364 < img src = { tintedDownloadImageURL } width = "12" height = "14" ref = "downloadImage" />
229365 Download { text }
230366 </ a >
0 commit comments