@@ -63,15 +63,137 @@ function updateTintedDownloadImage() {
63
63
64
64
Tinter . registerTintable ( updateTintedDownloadImage ) ;
65
65
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
+
66
184
module . exports = React . createClass ( {
67
185
displayName : 'MFileBody' ,
68
186
69
187
getInitialState : function ( ) {
70
188
return {
71
- decryptedUrl : ( this . props . decryptedUrl ? this . props . decryptedUrl : null ) ,
189
+ decryptedBlob : ( this . props . decryptedBlob ? this . props . decryptedBlob : null ) ,
72
190
} ;
73
191
} ,
74
192
193
+ contextTypes : {
194
+ appConfig : React . PropTypes . object ,
195
+ } ,
196
+
75
197
/**
76
198
* Extracts a human readable label for the file attachment to use as
77
199
* link text.
@@ -102,11 +224,7 @@ module.exports = React.createClass({
102
224
103
225
_getContentUrl : function ( ) {
104
226
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 ) ;
110
228
} ,
111
229
112
230
componentDidMount : function ( ) {
@@ -127,90 +245,108 @@ module.exports = React.createClass({
127
245
if ( this . refs . downloadImage ) {
128
246
this . refs . downloadImage . src = tintedDownloadImageURL ;
129
247
}
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
+ }
130
258
} ,
131
259
132
260
render : function ( ) {
133
261
const content = this . props . mxEvent . getContent ( ) ;
134
-
135
262
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 ( ) ;
136
266
const ErrorDialog = sdk . getComponent ( "dialogs.ErrorDialog" ) ;
137
267
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 ;
155
291
} ) ;
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
+ } , "*" ) ;
160
319
} ;
161
320
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
+ }
165
326
return (
166
- < span className = "mx_MFileBody" ref = "body" >
327
+ < span className = "mx_MFileBody" >
167
328
< 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" />
171
338
</ div >
172
339
</ span >
173
340
) ;
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.
209
345
if ( this . props . tileShape === "file_grid" ) {
210
346
return (
211
347
< span className = "mx_MFileBody" >
212
348
< 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" >
214
350
{ fileName }
215
351
</ a >
216
352
< div className = "mx_MImageBody_size" >
@@ -224,7 +360,7 @@ module.exports = React.createClass({
224
360
return (
225
361
< span className = "mx_MFileBody" >
226
362
< div className = "mx_MImageBody_download" >
227
- < a href = { contentUrl } target = "_blank" rel = "noopener" download = { downloadAttr } >
363
+ < a href = { contentUrl } target = "_blank" rel = "noopener" >
228
364
< img src = { tintedDownloadImageURL } width = "12" height = "14" ref = "downloadImage" />
229
365
Download { text }
230
366
</ a >
0 commit comments