AttachmentViewer.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. 'use strict';
  2. const common = require('../common');
  3. describe('AttachmentViewer', function () {
  4. describe('setAttachment, showAttachment, removeAttachment, hideAttachment, hideAttachmentPreview, hasAttachment, getAttachment & moveAttachmentTo', function () {
  5. this.timeout(30000);
  6. jsc.property(
  7. 'displays & hides data as requested',
  8. common.jscMimeTypes(),
  9. 'string',
  10. 'string',
  11. 'string',
  12. 'string',
  13. // eslint-disable-next-line complexity
  14. function (mimeType, rawdata, filename, prefix, postfix) {
  15. let clean = jsdom(),
  16. data = 'data:' + mimeType + ';base64,' + common.btoa(rawdata),
  17. mimePrefix = mimeType.substring(0, 6),
  18. previewSupported = (
  19. mimePrefix === 'image/' ||
  20. mimePrefix === 'audio/' ||
  21. mimePrefix === 'video/' ||
  22. mimeType.match(/\/pdf/i)
  23. ),
  24. results = [],
  25. result = '';
  26. // text node of attachment will truncate at null byte
  27. if (filename === '\u0000') {
  28. filename = '';
  29. }
  30. prefix = prefix.replace(/%(s|d)/g, '%%');
  31. postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
  32. $('body').html(
  33. '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
  34. '<div id="attachment" class="hidden"></div>' +
  35. '<div id="templates">' +
  36. '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
  37. '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
  38. '<a class="alert-link">Download attachment</a>' +
  39. '</div>' +
  40. '</div>'
  41. );
  42. // mock createObjectURL for jsDOM
  43. if (typeof window.URL.createObjectURL === 'undefined') {
  44. Object.defineProperty(
  45. window.URL,
  46. 'createObjectURL',
  47. {value: function(blob) {
  48. return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
  49. }}
  50. );
  51. }
  52. $.PrivateBin.AttachmentViewer.init();
  53. $.PrivateBin.Model.init();
  54. results.push(
  55. !$.PrivateBin.AttachmentViewer.hasAttachment() &&
  56. $('#attachment').hasClass('hidden') &&
  57. $('#attachment').children().length === 0 &&
  58. $('#attachmenttemplate').hasClass('hidden') &&
  59. $('#attachmentPreview').hasClass('hidden')
  60. );
  61. global.atob = common.atob;
  62. if (filename.length) {
  63. $.PrivateBin.AttachmentViewer.setAttachment(data, filename);
  64. } else {
  65. $.PrivateBin.AttachmentViewer.setAttachment(data);
  66. }
  67. // // beyond this point we will get the blob URL instead of the data
  68. data = window.URL.createObjectURL(data);
  69. const attachment = $.PrivateBin.AttachmentViewer.getAttachments();
  70. results.push(
  71. $.PrivateBin.AttachmentViewer.hasAttachment() &&
  72. $('#attachment').hasClass('hidden') &&
  73. $('#attachment').children().length > 0 &&
  74. $('#attachmentPreview').hasClass('hidden') &&
  75. attachment[0][0] === data &&
  76. attachment[0][1] === filename
  77. );
  78. $.PrivateBin.AttachmentViewer.showAttachment();
  79. results.push(
  80. !$('#attachment').hasClass('hidden') &&
  81. $('#attachment').children().length > 0 &&
  82. (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
  83. );
  84. $.PrivateBin.AttachmentViewer.hideAttachment();
  85. results.push(
  86. $('#attachment').hasClass('hidden') &&
  87. (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
  88. );
  89. if (previewSupported) {
  90. $.PrivateBin.AttachmentViewer.hideAttachmentPreview();
  91. results.push($('#attachmentPreview').hasClass('hidden'));
  92. }
  93. $.PrivateBin.AttachmentViewer.showAttachment();
  94. results.push(
  95. !$('#attachment').hasClass('hidden') &&
  96. (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
  97. );
  98. let element = $('<div>');
  99. $.PrivateBin.AttachmentViewer.moveAttachmentTo(element, attachment[0], prefix + '%s' + postfix);
  100. // messageIDs with links get a relaxed treatment
  101. if (prefix.indexOf('<a') === -1 && postfix.indexOf('<a') === -1) {
  102. result = $('<textarea>').text((prefix + filename + postfix)).text();
  103. } else {
  104. result = DOMPurify.sanitize(
  105. prefix + $.PrivateBin.Helper.htmlEntities(filename) + postfix, {
  106. ALLOWED_TAGS: ['a', 'i', 'span'],
  107. ALLOWED_ATTR: ['href', 'id']
  108. }
  109. );
  110. }
  111. if (filename.length) {
  112. results.push(
  113. element.find('a')[0].href === data &&
  114. element.find('a')[0].getAttribute('download') === filename &&
  115. element.find('a')[0].text === result
  116. );
  117. } else {
  118. results.push(element.find('a')[0].href === data);
  119. }
  120. $.PrivateBin.AttachmentViewer.removeAttachment();
  121. results.push(
  122. $('#attachment').hasClass('hidden') &&
  123. $('#attachment').children().length === 0 &&
  124. $('#attachmentPreview').hasClass('hidden')
  125. );
  126. clean();
  127. return results.every(element => element);
  128. }
  129. );
  130. it(
  131. 'sanitizes file names and MIME types in attachments',
  132. function() {
  133. const clean = jsdom();
  134. $('body').html(
  135. '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
  136. '<div id="attachment" class="hidden"></div>' +
  137. '<div id="templates">' +
  138. '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
  139. '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
  140. '<a class="alert-link">Download attachment</a>' +
  141. '</div>' +
  142. '</div>'
  143. );
  144. // mock createObjectURL for jsDOM
  145. if (typeof window.URL.createObjectURL === 'undefined') {
  146. Object.defineProperty(
  147. window.URL,
  148. 'createObjectURL',
  149. {value: function(blob) {
  150. return 'blob:' + blob.type + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
  151. }}
  152. );
  153. }
  154. $.PrivateBin.AttachmentViewer.init();
  155. $.PrivateBin.Model.init();
  156. global.atob = common.atob;
  157. const maliciousFileNames = [
  158. '<script>alert("☹️");//<a',
  159. '"><meta http-equiv="refresh" content="0;url=http://example.com/">.txt'
  160. ];
  161. for (const filename of maliciousFileNames) {
  162. $.PrivateBin.AttachmentViewer.setAttachment('data:;base64,', filename);
  163. assert.ok(!$('body').html().includes(filename), 'does not allow file name ' + filename);
  164. $.PrivateBin.AttachmentViewer.removeAttachment();
  165. }
  166. const maliciousMimeTypes = [
  167. // PDF bypasses
  168. 'application/x-pdf', // legacy, we don't need to support this
  169. 'text/html /pdf', // trips up Firefox and Chromium
  170. 'text/html(/pdf', // Chromium, see: https://chromium.googlesource.com/chromium/src/+/refs/tags/152.0.7949.0/net/base/mime_util.cc#521
  171. // SVG bypass
  172. 'text/html svg',
  173. 'text/html(svg',
  174. // invalid bytes after string
  175. 'image/png\x01',
  176. ];
  177. for (const mimeType of maliciousMimeTypes) {
  178. assert.ok(!$.PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'does not treat as safe MIME type: '+ mimeType);
  179. $.PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
  180. assert.ok(!$('body').html().includes(mimeType), 'does not allow MIME type: ' + mimeType);
  181. assert.ok(!$('body').html().includes(mimeType.toLowerCase()), 'does not allow lower cased MIME type: ' + mimeType);
  182. assert.ok(!$('body').html().includes('<img'), 'does not allow image MIME type: ' + mimeType);
  183. $.PrivateBin.AttachmentViewer.removeAttachment();
  184. }
  185. const supportedSafeMimeTypes = [
  186. 'text/plain',
  187. 'image/png',
  188. 'image/jpeg',
  189. ];
  190. for (const mimeType of supportedSafeMimeTypes) {
  191. assert.ok($.PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as safe MIME type: '+ mimeType);
  192. }
  193. const supportedPreviewMimeTypes = [
  194. 'application/pdf',
  195. 'audio/wav',
  196. 'video/avi',
  197. ];
  198. for (const mimeType of supportedPreviewMimeTypes) {
  199. assert.ok($.PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as safe preview MIME type: '+ mimeType);
  200. $.PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
  201. assert.ok($('body').html().includes(mimeType), 'allows MIME type: ' + mimeType);
  202. $.PrivateBin.AttachmentViewer.removeAttachment();
  203. }
  204. // special case: not a safe type, but renders a sanitized preview
  205. const svgMimeTypes = [
  206. 'image/svg+xml',
  207. 'image/SVG+xml',
  208. 'image/sVg',
  209. ];
  210. for (const mimeType of svgMimeTypes) {
  211. assert.ok(!$.PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as unsafe MIME type: '+ mimeType);
  212. $.PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
  213. assert.ok($('body').html().includes('image/svg+xml'), 'allows sanitized MIME type: ' + mimeType);
  214. $.PrivateBin.AttachmentViewer.removeAttachment();
  215. }
  216. clean();
  217. }
  218. );
  219. });
  220. });