Kaynağa Gözat

Merge commit from fork

Harden MIME type sanitization
El RIDO 1 gün önce
ebeveyn
işleme
e9482987d0
4 değiştirilmiş dosya ile 191 ekleme ve 83 silme
  1. 2 0
      CHANGELOG.md
  2. 54 39
      js/privatebin.js
  3. 134 43
      js/test/AttachmentViewer.js
  4. 1 1
      lib/Configuration.php

+ 2 - 0
CHANGELOG.md

@@ -1,8 +1,10 @@
 # PrivateBin version history
 
 ## 2.0.6 (not yet released)
+* CHANGED: Stricter MIME type validation, divergent files get no preview and forced download link
 * CHANGED: Upgrading libraries to: DOMpurify 3.4.12
 * CHANGED: Switch to PHP native JsonException type
+* FIXED: Restrict MIME types accepted for PDF & sanitized SVG previews to prevent HTML render fallback, incl. DOMpurify bypass using multi-byte encoded HTML entities
 * FIXED: Gracefully handle YOURLS replies with a 200 status code but no shorturl, instead of raising a TypeError
 * FIXED: Return "Invalid data." instead of HTTP 500 on malformed v2 JSON payloads (#1883)
 * FIXED: Dead PATH validation guard in Controller, the check for a missing trailing directory separator never ran (#1887)

+ 54 - 39
js/privatebin.js

@@ -1060,13 +1060,9 @@ jQuery.PrivateBin = (function($) {
          */
         function arraybufferToString(messageArray)
         {
-            const array = new Uint8Array(messageArray);
-            let message = '',
-                i       = 0;
-            while(i < array.length) {
-                message += String.fromCharCode(array[i++]);
-            }
-            return message;
+            return Array.from(new Uint8Array(messageArray))
+                .map(byte => String.fromCharCode(byte))
+                .join('');
         }
 
         /**
@@ -1082,11 +1078,10 @@ jQuery.PrivateBin = (function($) {
          */
         function stringToArraybuffer(message)
         {
-            const messageArray = new Uint8Array(message.length);
-            for (let i = 0; i < message.length; ++i) {
-                messageArray[i] = message.charCodeAt(i);
-            }
-            return messageArray;
+            return Uint8Array.from(
+                message,
+                character => character.charCodeAt(0)
+            );
         }
 
         /**
@@ -2959,10 +2954,10 @@ jQuery.PrivateBin = (function($) {
          function getBlobUrl(data, mimeType)
          {
             // Transform into a Blob
-            const buf = new Uint8Array(data.length);
-            for (let i = 0; i < data.length; ++i) {
-                buf[i] = data.charCodeAt(i);
-            }
+            const buf = Uint8Array.from(
+                data,
+                character => character.charCodeAt(0)
+            );
             const blob = new window.Blob(
                 [buf],
                 {
@@ -2999,22 +2994,22 @@ jQuery.PrivateBin = (function($) {
 
             // We explicitly do _not_ use the original mime type for the download link
             // to always force a download instead of potentially dangerous browser rendering/parsing/interpretation
-            let safeMimeType = 'application/octet-stream';
+            let sanitizedMimeType = 'application/octet-stream';
             if (me.isSafeMimeType(mimeType)) {
-                safeMimeType = mimeType;
+                sanitizedMimeType = mimeType;
             }
 
             // extract data and convert to binary
-            const rawData = attachmentData.substring(base64Start);
-            const decodedData = rawData.length > 0 ? atob(rawData) : '';
+            const base64Data = attachmentData.substring(base64Start);
+            const plainData = base64Data.length > 0 ? atob(base64Data) : '';
 
-            let blobUrl = getBlobUrl(decodedData, safeMimeType);
+            let blobUrl = getBlobUrl(plainData, sanitizedMimeType);
             attachmentLink.attr('href', blobUrl);
 
             if (typeof fileName !== 'undefined') {
                 attachmentLink.attr('download', fileName);
 
-                const fileSize = Helper.formatBytes(decodedData.length);
+                const fileSize = Helper.formatBytes(plainData.length);
                 const spans = template[0].querySelectorAll('span');
                 const span = spans[spans.length - 1];
                 span.textContent += ` (${fileName}, ${fileSize})`;
@@ -3024,18 +3019,36 @@ jQuery.PrivateBin = (function($) {
             // prevents executing embedded scripts when CSP is not set and user
             // right-clicks/long-taps and opens the SVG in a new tab - prevented
             // in the preview by use of an img tag, which disables scripts, too
-            if (mimeType.match(/^image\/.*svg/i)) {
-                const sanitizedData = DOMPurify.sanitize(
-                    decodedData,
-                    purifySvgConfig
-                );
-                blobUrl = getBlobUrl(sanitizedData, mimeType);
+            if (mimeType.startsWith('image\/svg')) {
+                try {
+                    // attempt to UTF-8 decode the SVG data
+                    const svgBuffer = Uint8Array.from(
+                        plainData,
+                        character => character.charCodeAt(0)
+                    );
+                    const utf8ValidatedSvgString = new TextDecoder(
+                        'utf-8',
+                        {fatal: true}
+                    ).decode(svgBuffer);
+                    const sanitizedData = DOMPurify.sanitize(
+                        utf8ValidatedSvgString,
+                        purifySvgConfig
+                    );
+                    sanitizedMimeType = 'image/svg+xml';
+                    blobUrl = getBlobUrl(sanitizedData, sanitizedMimeType);
+                } catch {
+                    // Invalid or non-UTF-8 SVG: download only, no preview as it
+                    // may be used to smuggle multi-byte sequences past DOMpurify
+                    // such as `&#x13c` to get `\x01<` & `&#x13e` to get `\x01>`
+                    sanitizedMimeType = 'application/octet-stream';
+                    blobUrl = getBlobUrl(plainData, sanitizedMimeType);
+                }
             }
 
             template.removeClass('hidden');
             $attachment.append(template);
 
-            me.handleBlobAttachmentPreview($attachmentPreview, blobUrl, mimeType);
+            me.handleBlobAttachmentPreview($attachmentPreview, blobUrl, sanitizedMimeType);
         };
 
 
@@ -3043,21 +3056,23 @@ jQuery.PrivateBin = (function($) {
          * Evaluates whether this is known a safe mime type.
          *
          * This means, the media can safely be displayed and e.g. no XSS should be possible.
-         * 
+         *
          * @name AttachmentViewer.isSafeMimeType
          * @function
          * @param {string}
          * @returns {bool}
          */
         me.isSafeMimeType = function(mimeType) {
-            return (
-                    mimeType.startsWith('image/') && 
+            return ((
+                    mimeType.startsWith('image/') &&
                     !mimeType.includes('svg')
                 ) ||
                 mimeType.startsWith('video/') ||
                 mimeType.startsWith('audio/') ||
-                mimeType.endsWith('/pdf') ||
-                mimeType === 'text/plain';
+                mimeType === 'application/pdf' ||
+                mimeType === 'text/plain') &&
+                // don't accept comments, stray characters, spaces, etc.
+                /^[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-z0-9][a-z0-9.+-]*[a-z0-9]$/.test(mimeType);
         }
 
         /**
@@ -3246,7 +3261,7 @@ jQuery.PrivateBin = (function($) {
             const mimeTypeEnd = attachmentData.indexOf(';');
 
             // extract mimeType
-            return attachmentData.substring(5, mimeTypeEnd);
+            return attachmentData.substring(5, mimeTypeEnd).toLowerCase();
         }
 
         /**
@@ -3342,12 +3357,12 @@ jQuery.PrivateBin = (function($) {
             const alreadyIncludesCurrentAttachment = $targetElement.find(`[src='${blobUrl}']`).length > 0;
 
             if (blobUrl && !alreadyIncludesCurrentAttachment) {
-                if (mimeType.toLowerCase().startsWith('image/')) {
+                if (mimeType.startsWith('image/')) {
                     const image = document.createElement('img');
                     image.setAttribute('src', blobUrl);
                     image.setAttribute('class', 'img-thumbnail');
                     $targetElement[0].appendChild(image);
-                } else if (mimeType.toLowerCase().startsWith('video/')) {
+                } else if (mimeType.startsWith('video/')) {
                     const video = document.createElement('video');
                     video.setAttribute('controls', 'true');
                     video.setAttribute('autoplay', 'true');
@@ -3357,7 +3372,7 @@ jQuery.PrivateBin = (function($) {
                     source.setAttribute('src', blobUrl);
                     video.appendChild(source);
                     $targetElement[0].appendChild(video);
-                } else if (mimeType.toLowerCase().startsWith('audio/')) {
+                } else if (mimeType.startsWith('audio/')) {
                     const audio = document.createElement('audio');
                     audio.setAttribute('controls', 'true');
                     audio.setAttribute('autoplay', 'true');
@@ -3366,7 +3381,7 @@ jQuery.PrivateBin = (function($) {
                     source.setAttribute('src', blobUrl);
                     audio.appendChild(source);
                     $targetElement[0].appendChild(audio);
-                } else if (mimeType.toLowerCase().endsWith('/pdf')) {
+                } else if (mimeType === 'application/pdf') {
                     const embed = document.createElement('embed');
                     embed.setAttribute('src', blobUrl);
                     embed.setAttribute('type', 'application/pdf');

+ 134 - 43
js/test/AttachmentViewer.js

@@ -1,5 +1,24 @@
 'use strict';
 const common = require('../common');
+const bodyTemplate = '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
+    '<div id="attachment" class="hidden"></div>' +
+    '<div id="templates">' +
+        '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
+            '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
+            '<a class="alert-link">Download attachment</a>' +
+        '</div>' +
+    '</div>';
+const createMockObjectURL = function(window, includeType = true) {
+    if (typeof window.URL.createObjectURL === 'undefined') {
+        Object.defineProperty(
+            window.URL,
+            'createObjectURL',
+            {value: function(blob) {
+                return 'blob:' + (includeType ? blob.type : location.origin) + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
+            }}
+        );
+    }
+}
 
 describe('AttachmentViewer', function () {
     describe('setAttachment, showAttachment, removeAttachment, hideAttachment, hideAttachmentPreview, hasAttachment, getAttachment & moveAttachmentTo', function () {
@@ -31,26 +50,8 @@ describe('AttachmentViewer', function () {
                 }
                 prefix  = prefix.replace(/%(s|d)/g, '%%');
                 postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
-                $('body').html(
-                    '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
-                    '<div id="attachment" class="hidden"></div>' +
-                    '<div id="templates">' +
-                        '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
-                            '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
-                            '<a class="alert-link">Download attachment</a>' +
-                        '</div>' +
-                    '</div>'
-                );
-                // mock createObjectURL for jsDOM
-                if (typeof window.URL.createObjectURL === 'undefined') {
-                    Object.defineProperty(
-                        window.URL,
-                        'createObjectURL',
-                        {value: function(blob) {
-                            return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
-                        }}
-                    );
-                }
+                $('body').html(bodyTemplate);
+                createMockObjectURL(window, false);
                 $.PrivateBin.AttachmentViewer.init();
                 $.PrivateBin.Model.init();
                 results.push(
@@ -66,7 +67,7 @@ describe('AttachmentViewer', function () {
                 } else {
                     $.PrivateBin.AttachmentViewer.setAttachment(data);
                 }
-                // // beyond this point we will get the blob URL instead of the data
+                // beyond this point we will get the blob URL instead of the data
                 data = window.URL.createObjectURL(data);
                 const attachment = $.PrivateBin.AttachmentViewer.getAttachments();
                 results.push(
@@ -131,29 +132,11 @@ describe('AttachmentViewer', function () {
         );
 
         it(
-            'sanitizes file names in attachments',
+            'sanitizes file names',
             function() {
                 const clean = jsdom();
-                $('body').html(
-                    '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
-                    '<div id="attachment" class="hidden"></div>' +
-                    '<div id="templates">' +
-                        '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
-                            '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
-                            '<a class="alert-link">Download attachment</a>' +
-                        '</div>' +
-                    '</div>'
-                );
-                // mock createObjectURL for jsDOM
-                if (typeof window.URL.createObjectURL === 'undefined') {
-                    Object.defineProperty(
-                        window.URL,
-                        'createObjectURL',
-                        {value: function(blob) {
-                            return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
-                        }}
-                    );
-                }
+                $('body').html(bodyTemplate);
+                createMockObjectURL(window);
                 $.PrivateBin.AttachmentViewer.init();
                 $.PrivateBin.Model.init();
                 global.atob = common.atob;
@@ -164,11 +147,119 @@ describe('AttachmentViewer', function () {
                 ];
                 for (const filename of maliciousFileNames) {
                     $.PrivateBin.AttachmentViewer.setAttachment('data:;base64,', filename);
-                    assert.ok(!$('body').html().includes(filename));
+                    assert.ok(!$('body').html().includes(filename), 'does not allow file name ' + filename);
+                    $.PrivateBin.AttachmentViewer.removeAttachment();
+                }
+                clean();
+            }
+        );
+
+        it(
+            'sanitizes MIME types in attachments',
+            function() {
+                const clean = jsdom();
+                $('body').html(bodyTemplate);
+                createMockObjectURL(window);
+                $.PrivateBin.AttachmentViewer.init();
+                $.PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                const maliciousMimeTypes = [
+                    // PDF bypasses
+                    'application/x-pdf',    // legacy, we don't need to support this
+                    'text/html /pdf',       // trips up Firefox and Chromium
+                    'text/html(/pdf',       // Chromium, see: https://chromium.googlesource.com/chromium/src/+/refs/tags/152.0.7949.0/net/base/mime_util.cc#521
+
+                    // SVG bypass
+                    'text/html svg',
+                    'text/html(svg',
+
+                    // invalid bytes after string
+                    'image/png\x01',
+                ];
+                for (const mimeType of maliciousMimeTypes) {
+                    assert.ok(!$.PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'does not treat as safe MIME type: '+ mimeType);
+                    $.PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
+                    assert.ok(!$('body').html().includes(mimeType), 'does not allow MIME type: ' + mimeType);
+                    assert.ok(!$('body').html().includes(mimeType.toLowerCase()), 'does not allow lower cased MIME type: ' + mimeType);
+                    assert.ok(!$('body').html().includes('<img'), 'does not allow image MIME type: ' + mimeType);
+                    $.PrivateBin.AttachmentViewer.removeAttachment();
+                }
+                clean();
+            }
+        );
+
+        it(
+            'supports safe MIME types in attachments',
+            function() {
+                const clean = jsdom();
+                $('body').html(bodyTemplate);
+                createMockObjectURL(window);
+                $.PrivateBin.AttachmentViewer.init();
+                $.PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                const supportedSafeMimeTypes = [
+                    'text/plain',
+                    'image/png',
+                    'image/jpeg',
+                ];
+                for (const mimeType of supportedSafeMimeTypes) {
+                    assert.ok($.PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as safe MIME type: '+ mimeType);
+                }
+                clean();
+            }
+        );
+
+        it(
+            'supports safe MIME type previews in attachments',
+            function() {
+                const clean = jsdom();
+                $('body').html(bodyTemplate);
+                createMockObjectURL(window);
+                $.PrivateBin.AttachmentViewer.init();
+                $.PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                const supportedPreviewMimeTypes = [
+                    'application/pdf',
+                    'audio/wav',
+                    'video/avi',
+                ];
+                for (const mimeType of supportedPreviewMimeTypes) {
+                    assert.ok($.PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as safe preview MIME type: '+ mimeType);
+                    $.PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
+                    assert.ok($('body').html().includes(mimeType), 'allows MIME type: ' + mimeType);
+                    $.PrivateBin.AttachmentViewer.removeAttachment();
                 }
                 clean();
             }
         );
 
+        it(
+            'special case sanitizes potentially unsafe SVG previews',
+            function() {
+                const clean = jsdom();
+                $('body').html(bodyTemplate);
+                createMockObjectURL(window);
+                $.PrivateBin.AttachmentViewer.init();
+                $.PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                // special case: not a safe type, but renders a sanitized preview
+                const svgMimeTypes = [
+                    'image/svg+xml',
+                    'image/SVG+xml',
+                    'image/sVg',
+                ];
+                for (const mimeType of svgMimeTypes) {
+                    assert.ok(!$.PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as unsafe MIME type: '+ mimeType);
+                    $.PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
+                    assert.ok($('body').html().includes('image/svg+xml'), 'allows sanitized MIME type: ' + mimeType);
+                    $.PrivateBin.AttachmentViewer.removeAttachment();
+                }
+                clean();
+            }
+        );
     });
 });

+ 1 - 1
lib/Configuration.php

@@ -122,7 +122,7 @@ class Configuration
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/legacy.js'           => 'sha512-RQEo1hxpNc37i+jz/D9/JiAZhG8GFx3+SNxjYnI7jUgirDIqrCSj6QPAAZeaidditcWzsJ3jxfEj5lVm7ZwTRQ==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-g58rPgrKnvxBDnT6lzYazdEfgY3XhRh7Yt7i1jkJw4oSvN0k/VPZ4zBSL/dCGfXGIOHqlqmX8DO5UFv2XYqsXg==',
+            'js/privatebin.js'       => 'sha512-1A1sPtX5Dq1+etzpxjFUx8SxtH923DVw+Prh41NcU7AUQd1Lkj6XtCQGl2LKJf4FyYNVl5is00lbs9ZnEQ6arw==',
             'js/purify-3.4.12.js'    => 'sha512-Akf6HnAJZm0sWWWI4gp2GYff0NDnHUB02XJE5S7Hdq/Z5xtMjkuFacsDA8ZtViv1gi+onBxMhEMIaGyQeGxBng==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',