Просмотр исходного кода

Merge branch 'displayEncoding'

El RIDO 6 лет назад
Родитель
Сommit
f940f17bba
6 измененных файлов с 198 добавлено и 88 удалено
  1. 1 1
      js/common.js
  2. 89 75
      js/privatebin.js
  3. 2 2
      js/test/AttachmentViewer.js
  4. 102 6
      js/test/I18n.js
  5. 3 3
      tpl/bootstrap.php
  6. 1 1
      tpl/page.php

+ 1 - 1
js/common.js

@@ -36,7 +36,7 @@ var a2zString    = ['a','b','c','d','e','f','g','h','i','j','k','l','m',
             return c.toUpperCase();
         })
     ),
-    schemas = ['ftp','gopher','http','https','ws','wss'],
+    schemas = ['ftp','http','https'],
     supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'pl', 'pt', 'oc', 'ru', 'sl', 'zh'],
     mimeTypes = ['image/png', 'application/octet-stream'],
     formats = ['plaintext', 'markdown', 'syntaxhighlighting'],

+ 89 - 75
js/privatebin.js

@@ -189,6 +189,26 @@ jQuery.PrivateBin = (function($, RawDeflate) {
     const Helper = (function () {
         const me = {};
 
+        /**
+         * character to HTML entity lookup table
+         *
+         * @see    {@link https://github.com/janl/mustache.js/blob/master/mustache.js#L60}
+         * @name Helper.entityMap
+         * @private
+         * @enum   {Object}
+         * @readonly
+         */
+        var entityMap = {
+            '&': '&',
+            '<': '&lt;',
+            '>': '&gt;',
+            '"': '&quot;',
+            "'": '&#39;',
+            '/': '&#x2F;',
+            '`': '&#x60;',
+            '=': '&#x3D;'
+        };
+
         /**
          * cache for script location
          *
@@ -302,19 +322,12 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             let format = args[0],
                 i = 1;
             return format.replace(/%(s|d)/g, function (m) {
-                // m is the matched format, e.g. %s, %d
                 let val = args[i];
-                // A switch statement so that the formatter can be extended.
-                switch (m)
-                {
-                    case '%d':
-                        val = parseFloat(val);
-                        if (isNaN(val)) {
-                            val = 0;
-                        }
-                        break;
-                    default:
-                        // Default is %s
+                if (m === '%d') {
+                    val = parseFloat(val);
+                    if (isNaN(val)) {
+                        val = 0;
+                    }
                 }
                 ++i;
                 return val;
@@ -392,6 +405,23 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             return new Comment(data);
         };
 
+        /**
+         * convert all applicable characters to HTML entities
+         *
+         * @see    {@link https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html}
+         * @name   Helper.htmlEntities
+         * @function
+         * @param  {string} str
+         * @return {string} escaped HTML
+         */
+        me.htmlEntities = function(str) {
+            return String(str).replace(
+                /[&<>"'`=\/]/g, function(s) {
+                    return entityMap[s];
+                }
+            );
+        }
+
         /**
          * resets state, used for unit testing
          *
@@ -442,32 +472,6 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             return expirationDate;
         };
 
-        /**
-         * encode all applicable characters to HTML entities
-         *
-         * @see    {@link https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html}
-         *
-         * @name   Helper.htmlEntities
-         * @function
-         * @param  string str
-         * @return string escaped HTML
-         */
-        me.htmlEntities = function(str) {
-            // using textarea, since other tags may allow and execute scripts, even when detached from DOM
-            let holder = document.createElement('textarea');
-            holder.textContent = str;
-            // as per OWASP recommendation, also encoding quotes and slash
-            return holder.innerHTML.replace(
-                /["'\/]/g,
-                function(s) {
-                    return {
-                        '"': '&quot;',
-                        "'": '&#x27;',
-                        '/': '&#x2F;'
-                    }[s];
-                });
-        };
-
         return me;
     })();
 
@@ -538,10 +542,14 @@ jQuery.PrivateBin = (function($, RawDeflate) {
          *
          * Optionally pass a jQuery element as the first parameter, to automatically
          * let the text of this element be replaced. In case the (asynchronously
-         * loaded) language is not downloadet yet, this will make sure the string
-         * is replaced when it is actually loaded.
-         * So for easy translations passing the jQuery object to apply it to is
-         * more save, especially when they are loaded in the beginning.
+         * loaded) language is not downloaded yet, this will make sure the string
+         * is replaced when it eventually gets loaded. Using this is both simpler
+         * and more secure, as it avoids potential XSS when inserting text.
+         * The next parameter is the message ID, matching the ones found in
+         * the translation files under the i18n directory.
+         * Any additional parameters will get inserted into the message ID in
+         * place of %s (strings) or %d (digits), applying the appropriate plural
+         * in case of digits. See also Helper.sprintf().
          *
          * @name   I18n.translate
          * @function
@@ -619,31 +627,39 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             }
 
             // messageID may contain links, but should be from a trusted source (code or translation JSON files)
-            let containsNoLinks = args[0].indexOf('<a') === -1;
-            for (let i = 0; i < args.length; ++i) {
-                // parameters (i > 0) may never contain HTML as they may come from untrusted parties
-                if (i > 0 || containsNoLinks) {
-                    args[i] = Helper.htmlEntities(args[i]);
+            let containsLinks = args[0].indexOf('<a') !== -1;
+
+            // prevent double encoding, when we insert into a text node
+            if (containsLinks || $element === null) {
+                for (let i = 0; i < args.length; ++i) {
+                    // parameters (i > 0) may never contain HTML as they may come from untrusted parties
+                    if (i > 0 || !containsLinks) {
+                        args[i] = Helper.htmlEntities(args[i]);
+                    }
                 }
             }
-
             // format string
             let output = Helper.sprintf.apply(this, args);
 
-            // if $element is given, apply text to element
+            if (containsLinks) {
+                // only allow tags/attributes we actually use in translations
+                output = DOMPurify.sanitize(
+                    output, {
+                        ALLOWED_TAGS: ['a', 'br', 'i', 'span'],
+                        ALLOWED_ATTR: ['href', 'id']
+                    }
+                );
+            }
+
+            // if $element is given, insert translation
             if ($element !== null) {
-                if (containsNoLinks) {
-                    // avoid HTML entity encoding if translation contains links
-                    $element.text(output);
+                if (containsLinks) {
+                    $element.html(output);
                 } else {
-                    // only allow tags/attributes we actually use in our translations
-                    $element.html(
-                        DOMPurify.sanitize(output, {
-                            ALLOWED_TAGS: ['a', 'br', 'i', 'span'],
-                            ALLOWED_ATTR: ['href', 'id']
-                        })
-                    );
+                    // text node takes care of entity encoding
+                    $element.text(output);
                 }
+                return '';
             }
 
             return output;
@@ -1876,11 +1892,10 @@ jQuery.PrivateBin = (function($, RawDeflate) {
                             return a.length - b.length;
                         })[0];
                         if (typeof shortUrl === 'string' && shortUrl.length > 0) {
-                            $('#pastelink').html(
-                                I18n._(
-                                    'Your paste is <a id="pasteurl" href="%s">%s</a> <span id="copyhint">(Hit [Ctrl]+[c] to copy)</span>',
-                                    shortUrl, shortUrl
-                                )
+                            I18n._(
+                                $('#pastelink'),
+                                'Your paste is <a id="pasteurl" href="%s">%s</a> <span id="copyhint">(Hit [Ctrl]+[c] to copy)</span>',
+                                shortUrl, shortUrl
                             );
                             // we disable the button to avoid calling shortener again
                             $shortenButton.addClass('buttondisabled');
@@ -1935,11 +1950,10 @@ jQuery.PrivateBin = (function($, RawDeflate) {
          */
         me.createPasteNotification = function(url, deleteUrl)
         {
-            $('#pastelink').html(
-                I18n._(
-                    'Your paste is <a id="pasteurl" href="%s">%s</a> <span id="copyhint">(Hit [Ctrl]+[c] to copy)</span>',
-                    url, url
-                )
+            I18n._(
+                $('#pastelink'),
+                'Your paste is <a id="pasteurl" href="%s">%s</a> <span id="copyhint">(Hit [Ctrl]+[c] to copy)</span>',
+                url, url
             );
             // save newly created element
             $pasteUrl = $('#pasteurl');
@@ -1947,7 +1961,8 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             $pasteUrl.click(pasteLinkClick);
 
             // delete link
-            $('#deletelink').html('<a href="' + deleteUrl + '">' + I18n._('Delete data') + '</a>');
+            $('#deletelink').html('<a href="' + deleteUrl + '"></a>');
+            I18n._($('#deletelink a').first(), 'Delete data');
 
             // enable shortener button
             $shortenButton.removeClass('buttondisabled');
@@ -3710,8 +3725,9 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             const $emailconfirmmodal = $('#emailconfirmmodal');
             if ($emailconfirmmodal.length > 0) {
                 if (expirationDate !== null) {
-                    $emailconfirmmodal.find('#emailconfirm-display').text(
-                        I18n._('Recipient may become aware of your timezone, convert time to UTC?')
+                    I18n._(
+                        $emailconfirmmodal.find('#emailconfirm-display'),
+                        'Recipient may become aware of your timezone, convert time to UTC?'
                     );
                     const $emailconfirmTimezoneCurrent = $emailconfirmmodal.find('#emailconfirm-timezone-current');
                     const $emailconfirmTimezoneUtc = $emailconfirmmodal.find('#emailconfirm-timezone-utc');
@@ -3911,9 +3927,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
                 });
             } catch (error) {
                 console.error(error);
-                Alert.showError(
-                    I18n._('Cannot calculate expiration date.')
-                );
+                Alert.showError('Cannot calculate expiration date.');
             }
         }
 

+ 2 - 2
js/test/AttachmentViewer.js

@@ -86,9 +86,9 @@ describe('AttachmentViewer', function () {
                 $.PrivateBin.AttachmentViewer.moveAttachmentTo(element, prefix + '%s' + postfix);
                 // messageIDs with links get a relaxed treatment
                 if (prefix.indexOf('<a') === -1 && postfix.indexOf('<a') === -1) {
-                    result = $.PrivateBin.Helper.htmlEntities(prefix + filename + postfix);
+                    result = $('<textarea>').text((prefix + filename + postfix)).text();
                 } else {
-                    result = $('<div>').html(prefix + $.PrivateBin.Helper.htmlEntities(filename) + postfix).html();
+                    result = prefix + $.PrivateBin.Helper.htmlEntities(filename) + postfix;
                 }
                 if (filename.length) {
                     results.push(

+ 102 - 6
js/test/I18n.js

@@ -3,6 +3,7 @@ var common = require('../common');
 
 describe('I18n', function () {
     describe('translate', function () {
+        this.timeout(30000);
         before(function () {
             $.PrivateBin.I18n.reset();
         });
@@ -32,14 +33,41 @@ describe('I18n', function () {
                 var fakeAlias = $.PrivateBin.I18n._(fake);
                 $.PrivateBin.I18n.reset();
 
-                messageId = $.PrivateBin.Helper.htmlEntities(messageId);
+                if (messageId.indexOf('<a') === -1) {
+                    messageId = $.PrivateBin.Helper.htmlEntities(messageId);
+                } else {
+                    messageId = DOMPurify.sanitize(
+                        messageId, {
+                            ALLOWED_TAGS: ['a', 'br', 'i', 'span'],
+                            ALLOWED_ATTR: ['href', 'id']
+                        }
+                    );
+                }
                 return messageId === result && messageId === alias &&
                     messageId === pluralResult && messageId === pluralAlias &&
                     messageId === fakeResult && messageId === fakeAlias;
             }
         );
         jsc.property(
-            'replaces %s in strings with first given parameter',
+            'replaces %s in strings with first given parameter, encoding all, when no link is in the messageID',
+            'string',
+            '(small nearray) string',
+            'string',
+            function (prefix, params, postfix) {
+                prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                params[0] = params[0].replace(/%(s|d)/g, '%%');
+                postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                const translation = $.PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
+                params.unshift(prefix + '%s' + postfix);
+                const result = $.PrivateBin.I18n.translate.apply(this, params);
+                $.PrivateBin.I18n.reset();
+                const alias = $.PrivateBin.I18n._.apply(this, params);
+                $.PrivateBin.I18n.reset();
+                return translation === result && translation === alias;
+            }
+        );
+        jsc.property(
+            'replaces %s in strings with first given parameter, encoding params only, when a link is part of the messageID',
             'string',
             '(small nearray) string',
             'string',
@@ -47,15 +75,83 @@ describe('I18n', function () {
                 prefix    =    prefix.replace(/%(s|d)/g, '%%');
                 params[0] = params[0].replace(/%(s|d)/g, '%%');
                 postfix   =   postfix.replace(/%(s|d)/g, '%%');
-                var translation = $.PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
-                params.unshift(prefix + '%s' + postfix);
-                var result = $.PrivateBin.I18n.translate.apply(this, params);
+                const translation = DOMPurify.sanitize(
+                    prefix + $.PrivateBin.Helper.htmlEntities(params[0]) + '<a></a>' + postfix, {
+                        ALLOWED_TAGS: ['a', 'br', 'i', 'span'],
+                        ALLOWED_ATTR: ['href', 'id']
+                    }
+                );
+                params.unshift(prefix + '%s<a></a>' + postfix);
+                const result = $.PrivateBin.I18n.translate.apply(this, params);
                 $.PrivateBin.I18n.reset();
-                var alias = $.PrivateBin.I18n._.apply(this, params);
+                const alias = $.PrivateBin.I18n._.apply(this, params);
                 $.PrivateBin.I18n.reset();
                 return translation === result && translation === alias;
             }
         );
+        jsc.property(
+            'replaces %s in strings with first given parameter into an element, encoding all, when no link is in the messageID',
+            'string',
+            '(small nearray) string',
+            'string',
+            function (prefix, params, postfix) {
+                prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                params[0] = params[0].replace(/%(s|d)/g, '%%');
+                postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                const translation = $('<textarea>').text((prefix + params[0] + postfix)).text();
+                let args = Array.prototype.slice.call(params);
+                args.unshift(prefix + '%s' + postfix);
+                let clean = jsdom();
+                $('body').html('<div id="i18n"></div>');
+                args.unshift($('#i18n'));
+                $.PrivateBin.I18n.translate.apply(this, args);
+                const result = $('#i18n').text();
+                $.PrivateBin.I18n.reset();
+                clean();
+                clean = jsdom();
+                $('body').html('<div id="i18n"></div>');
+                args[0] = $('#i18n');
+                $.PrivateBin.I18n._.apply(this, args);
+                const alias = $('#i18n').text();
+                $.PrivateBin.I18n.reset();
+                clean();
+                return translation === result && translation === alias;
+            }
+        );
+        jsc.property(
+            'replaces %s in strings with first given parameter into an element, encoding params only, when a link is part of the messageID inserted',
+            'string',
+            '(small nearray) string',
+            'string',
+            function (prefix, params, postfix) {
+                prefix    =    prefix.replace(/%(s|d)/g, '%%').trim();
+                params[0] = params[0].replace(/%(s|d)/g, '%%').trim();
+                postfix   =   postfix.replace(/%(s|d)/g, '%%').trim();
+                const translation = DOMPurify.sanitize(
+                    prefix + $.PrivateBin.Helper.htmlEntities(params[0]) + '<a></a>' + postfix, {
+                        ALLOWED_TAGS: ['a', 'br', 'i', 'span'],
+                        ALLOWED_ATTR: ['href', 'id']
+                    }
+                );
+                let args = Array.prototype.slice.call(params);
+                args.unshift(prefix + '%s<a></a>' + postfix);
+                let clean = jsdom();
+                $('body').html('<div id="i18n"></div>');
+                args.unshift($('#i18n'));
+                $.PrivateBin.I18n.translate.apply(this, args);
+                const result = $('#i18n').html();
+                $.PrivateBin.I18n.reset();
+                clean();
+                clean = jsdom();
+                $('body').html('<div id="i18n"></div>');
+                args[0] = $('#i18n');
+                $.PrivateBin.I18n._.apply(this, args);
+                const alias = $('#i18n').html();
+                $.PrivateBin.I18n.reset();
+                clean();
+                return translation === result && translation === alias;
+            }
+        );
     });
 
     describe('getPluralForm', function () {

+ 3 - 3
tpl/bootstrap.php

@@ -72,7 +72,7 @@ endif;
 ?>
 		<script type="text/javascript" data-cfasync="false" src="js/purify-2.0.7.js" integrity="sha512-XjNEK1xwh7SJ/7FouwV4VZcGW9cMySL3SwNpXgrURLBcXXQYtZdqhGoNdEwx9vwLvFjUGDQVNgpOrTsXlSTiQg==" crossorigin="anonymous"></script>
 		<script type="text/javascript" data-cfasync="false" src="js/legacy.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-LYos+qXHIRqFf5ZPNphvtTB0cgzHUizu2wwcOwcwz/VIpRv9lpcBgPYz4uq6jx0INwCAj6Fbnl5HoKiLufS2jg==" crossorigin="anonymous"></script>
-		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-RJwTFnQikma3rIso3dda8anhgsoFT7dZdARUw9m5/9Gl9A+U3B3H7LEjuLRFmC0rV86SfuXNnKwILAbF96Hr/w==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-Q7yHFlVuPYWw/SJFiMv83PPVwGKqBwoqZhNtHAwkTIxocS6Zpqyj1I0/nUCRWv15xuurctViB3lSVs6s+7f0jw==" crossorigin="anonymous"></script>
 		<link rel="apple-touch-icon" href="img/apple-touch-icon.png?<?php echo rawurlencode($VERSION); ?>" sizes="180x180" />
 		<link rel="icon" type="image/png" href="img/favicon-32x32.png?<?php echo rawurlencode($VERSION); ?>" sizes="32x32" />
 		<link rel="icon" type="image/png" href="img/favicon-16x16.png?<?php echo rawurlencode($VERSION); ?>" sizes="16x16" />
@@ -517,8 +517,8 @@ endif;
 ?>
 				</div>
 				<ul id="editorTabs" class="nav nav-tabs hidden">
-					<li role="presentation" class="active"><a role="tab" aria-selected="true" aria-controls="editorTabs" id="messageedit" href="#"><?php echo I18n::_('Editor'); ?></a></li>
-					<li role="presentation"><a role="tab" aria-selected="false" aria-controls="editorTabs" id="messagepreview" href="#"><?php echo I18n::_('Preview'); ?></a></li>
+					<li role="presentation" class="active"><a id="messageedit" href="#"><?php echo I18n::_('Editor'); ?></a></li>
+					<li role="presentation"><a id="messagepreview" href="#"><?php echo I18n::_('Preview'); ?></a></li>
 					<li role="presentation" class="pull-right">
 <?php
 if ($isPage):

+ 1 - 1
tpl/page.php

@@ -50,7 +50,7 @@ endif;
 ?>
 		<script type="text/javascript" data-cfasync="false" src="js/purify-2.0.7.js" integrity="sha512-XjNEK1xwh7SJ/7FouwV4VZcGW9cMySL3SwNpXgrURLBcXXQYtZdqhGoNdEwx9vwLvFjUGDQVNgpOrTsXlSTiQg==" crossorigin="anonymous"></script>
 		<script type="text/javascript" data-cfasync="false" src="js/legacy.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-LYos+qXHIRqFf5ZPNphvtTB0cgzHUizu2wwcOwcwz/VIpRv9lpcBgPYz4uq6jx0INwCAj6Fbnl5HoKiLufS2jg==" crossorigin="anonymous"></script>
-		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-RJwTFnQikma3rIso3dda8anhgsoFT7dZdARUw9m5/9Gl9A+U3B3H7LEjuLRFmC0rV86SfuXNnKwILAbF96Hr/w==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-Q7yHFlVuPYWw/SJFiMv83PPVwGKqBwoqZhNtHAwkTIxocS6Zpqyj1I0/nUCRWv15xuurctViB3lSVs6s+7f0jw==" crossorigin="anonymous"></script>
 		<link rel="apple-touch-icon" href="img/apple-touch-icon.png?<?php echo rawurlencode($VERSION); ?>" sizes="180x180" />
 		<link rel="icon" type="image/png" href="img/favicon-32x32.png?<?php echo rawurlencode($VERSION); ?>" sizes="32x32" />
 		<link rel="icon" type="image/png" href="img/favicon-16x16.png?<?php echo rawurlencode($VERSION); ?>" sizes="16x16" />