Explorar o código

backporting double encoding fixes from #560

El RIDO %!s(int64=6) %!d(string=hai) anos
pai
achega
6ccbad612d
Modificáronse 9 ficheiros con 249 adicións e 125 borrados
  1. 2 0
      CHANGELOG.md
  2. 1 1
      js/common.js
  3. 97 74
      js/privatebin.js
  4. 13 8
      js/test/AttachmentViewer.js
  5. 25 27
      js/test/Helper.js
  6. 108 9
      js/test/I18n.js
  7. 1 4
      js/test/PasteStatus.js
  8. 1 1
      tpl/bootstrap.php
  9. 1 1
      tpl/page.php

+ 2 - 0
CHANGELOG.md

@@ -1,5 +1,7 @@
 # PrivateBin version history
 
+  * **1.2.3 (2020-02-16)**
+    * FIXED: HTML entity double encoding issues introduced in 1.3.2 (#560)
   * **1.2.2 (2020-01-11)**
     * CHANGED: Upgrading libraries to: bootstrap 3.4.1, DOMpurify 2.0.7, jQuery 3.4.1, kjua 0.6.0, Showdown 1.9.1 & SJCL 1.0.8
     * FIXED: HTML injection via unescaped attachment filename (#554)

+ 1 - 1
js/common.js

@@ -32,7 +32,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'],

+ 97 - 74
js/privatebin.js

@@ -68,6 +68,26 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
          */
         var baseUri = null;
 
+        /**
+         * 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;'
+        };
+
         /**
          * converts a duration (in seconds) into human friendly approximation
          *
@@ -171,19 +191,12 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
             var format = args[0],
                 i = 1;
             return format.replace(/%(s|d)/g, function (m) {
-                // m is the matched format, e.g. %s, %d
                 var 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;
@@ -237,15 +250,21 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
         };
 
         /**
-         * resets state, used for unit testing
+         * convert all applicable characters to HTML entities
          *
-         * @name   Helper.reset
+         * @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.reset = function()
-        {
-            baseUri = null;
-        };
+        me.htmlEntities = function(str) {
+            return String(str).replace(
+                /[&<>"'`=\/]/g, function(s) {
+                    return entityMap[s];
+                }
+            );
+        }
 
         /**
          * checks whether this is a bot we dislike
@@ -268,29 +287,14 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
         }
 
         /**
-         * encode all applicable characters to HTML entities
-         *
-         * @see    {@link https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html}
+         * resets state, used for unit testing
          *
-         * @name   Helper.htmlEntities
+         * @name   Helper.reset
          * @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];
-                });
+        me.reset = function()
+        {
+            baseUri = null;
         };
 
         return me;
@@ -363,10 +367,14 @@ jQuery.PrivateBin = (function($, sjcl, Base64, 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
@@ -446,31 +454,39 @@ jQuery.PrivateBin = (function($, sjcl, Base64, 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]);
+            var containsLinks = args[0].indexOf('<a') !== -1;
+
+            // prevent double encoding, when we insert into a text node
+            if (containsLinks || $element === null) {
+                for (var i = 0; i < args.length; ++i) {
+                    // parameters (i > 0) may never contain HTML as they may come from untrusted parties
+                    if ((containsLinks ? i > 1 : i > 0) || !containsLinks) {
+                        args[i] = Helper.htmlEntities(args[i]);
+                    }
                 }
             }
-
             // format string
             var 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', '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;
@@ -1342,11 +1358,10 @@ jQuery.PrivateBin = (function($, sjcl, Base64, 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');
@@ -1354,7 +1369,8 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
             $pasteUrl.click(pasteLinkClick);
 
             // shorten button
-            $('#deletelink').html('<a href="' + deleteUrl + '">' + I18n._('Delete data') + '</a>');
+            $('#deletelink').html('<a href="' + deleteUrl + '"></a>');
+            I18n._($('#deletelink a').first(), 'Delete data');
 
             // show result
             $pasteSuccess.removeClass('hidden');
@@ -1810,10 +1826,13 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
             }
 
             // escape HTML entities, link URLs, sanitize
-            var escapedLinkedText = Helper.urls2links(
-                    Helper.htmlEntities(text)
-                  ),
-                  sanitizedLinkedText = DOMPurify.sanitize(escapedLinkedText);
+            var escapedLinkedText = Helper.urls2links(text),
+                sanitizedLinkedText = DOMPurify.sanitize(
+                    escapedLinkedText, {
+                        ALLOWED_TAGS: ['a'],
+                        ALLOWED_ATTR: ['href', 'rel']
+                    }
+                );
             $plainText.html(sanitizedLinkedText);
             $prettyPrint.html(sanitizedLinkedText);
 
@@ -2625,7 +2644,10 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
             // set & parse text
             $commentEntryData.html(
                 DOMPurify.sanitize(
-                    Helper.urls2links(commentText)
+                    Helper.urls2links(commentText), {
+                        ALLOWED_TAGS: ['a'],
+                        ALLOWED_ATTR: ['href', 'rel']
+                    }
                 )
             );
 
@@ -4418,9 +4440,7 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
             Uploader.setUnencryptedData('deletetoken', deleteToken);
 
             Uploader.setFailure(function () {
-                Alert.showError(
-                    I18n._('Could not delete the paste, it was not stored in burn after reading mode.')
-                );
+                Alert.showError('Could not delete the paste, it was not stored in burn after reading mode.');
             });
             Uploader.run();
         };
@@ -4436,7 +4456,10 @@ jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
             // first load translations
             I18n.loadTranslations();
 
-            DOMPurify.setConfig({SAFE_FOR_JQUERY: true});
+            DOMPurify.setConfig({
+                ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
+                SAFE_FOR_JQUERY: true
+            });
 
             // initialize other modules/"classes"
             Alert.init();

+ 13 - 8
js/test/AttachmentViewer.js

@@ -8,13 +8,13 @@ describe('AttachmentViewer', function () {
         jsc.property(
             'displays & hides data as requested',
             common.jscMimeTypes(),
-            jsc.nearray(common.jscBase64String()),
             'string',
             'string',
             'string',
-            function (mimeType, base64, filename, prefix, postfix) {
-                var clean = jsdom(),
-                    data = 'data:' + mimeType + ';base64,' + base64.join(''),
+            'string',
+            function (mimeType, rawdata, filename, prefix, postfix) {
+                let clean = jsdom(),
+                    data = 'data:' + mimeType + ';base64,' + btoa(rawdata),
                     previewSupported = (
                         mimeType.substring(0, 6) === 'image/' ||
                         mimeType.substring(0, 6) === 'audio/' ||
@@ -23,7 +23,7 @@ describe('AttachmentViewer', function () {
                     ),
                     results = [],
                     result = '';
-                prefix = prefix.replace(/%(s|d)/g, '%%');
+                prefix  = prefix.replace(/%(s|d)/g, '%%');
                 postfix = postfix.replace(/%(s|d)/g, '%%');
                 $('body').html(
                     '<div id="attachment" role="alert" class="hidden alert ' +
@@ -43,7 +43,7 @@ describe('AttachmentViewer', function () {
                 } else {
                     $.PrivateBin.AttachmentViewer.setAttachment(data);
                 }
-                var attachment = $.PrivateBin.AttachmentViewer.getAttachment();
+                const attachment = $.PrivateBin.AttachmentViewer.getAttachment();
                 results.push(
                     $.PrivateBin.AttachmentViewer.hasAttachment() &&
                     $('#attachment').hasClass('hidden') &&
@@ -74,9 +74,14 @@ 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 = DOMPurify.sanitize(
+                        prefix + $.PrivateBin.Helper.htmlEntities(filename) + postfix, {
+                            ALLOWED_TAGS: ['a', 'i', 'span'],
+                            ALLOWED_ATTR: ['href', 'id']
+                        }
+                    );
                 }
                 if (filename.length) {
                     results.push(

+ 25 - 27
js/test/Helper.js

@@ -93,11 +93,11 @@ describe('Helper', function () {
             jsc.array(common.jscHashString()),
             'string',
             function (prefix, schema, address, query, fragment, postfix) {
-                var query    = query.join(''),
-                    fragment = fragment.join(''),
-                    url      = schema + '://' + address.join('') + '/?' + query + '#' + fragment,
-                    prefix   = $.PrivateBin.Helper.htmlEntities(prefix),
-                    postfix  = ' ' + $.PrivateBin.Helper.htmlEntities(postfix);
+                query    = query.join('');
+                fragment = fragment.join('');
+                prefix   = $.PrivateBin.Helper.htmlEntities(prefix);
+                postfix  = ' ' + $.PrivateBin.Helper.htmlEntities(postfix);
+                let url  = schema + '://' + address.join('') + '/?' + query + '#' + fragment;
 
                 // special cases: When the query string and fragment imply the beginning of an HTML entity, eg. &#0 or &#x
                 if (
@@ -118,9 +118,9 @@ describe('Helper', function () {
             jsc.array(common.jscQueryString()),
             'string',
             function (prefix, query, postfix) {
-                var url     = 'magnet:?' + query.join('').replace(/^&+|&+$/gm,''),
-                    prefix  = $.PrivateBin.Helper.htmlEntities(prefix),
-                    postfix = $.PrivateBin.Helper.htmlEntities(postfix);
+                prefix   = $.PrivateBin.Helper.htmlEntities(prefix);
+                postfix  = $.PrivateBin.Helper.htmlEntities(postfix);
+                let url  = 'magnet:?' + query.join('').replace(/^&+|&+$/gm,'');
                 return prefix + '<a href="' + url + '" rel="nofollow">' + url + '</a> ' + postfix === $.PrivateBin.Helper.urls2links(prefix + url + ' ' + postfix);
             }
         );
@@ -175,9 +175,9 @@ describe('Helper', function () {
             'string',
             'string',
             function (prefix, uint, middle, string, postfix) {
-                prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                middle  =  middle.replace(/%(s|d)/g, '%%');
-                postfix = postfix.replace(/%(s|d)/g, '%%');
+                prefix  =  prefix.replace(/%(s|d)/g, '');
+                middle  =  middle.replace(/%(s|d)/g, '');
+                postfix = postfix.replace(/%(s|d)/g, '');
                 var params = [prefix + '%d' + middle + '%s' + postfix, uint, string],
                     result = prefix + uint + middle + string + postfix;
                 return result === $.PrivateBin.Helper.sprintf.apply(this, params);
@@ -191,9 +191,9 @@ describe('Helper', function () {
             'string',
             'string',
             function (prefix, uint, middle, string, postfix) {
-                prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                middle  =  middle.replace(/%(s|d)/g, '%%');
-                postfix = postfix.replace(/%(s|d)/g, '%%');
+                prefix  =  prefix.replace(/%(s|d)/g, '');
+                middle  =  middle.replace(/%(s|d)/g, '');
+                postfix = postfix.replace(/%(s|d)/g, '');
                 var params = [prefix + '%s' + middle + '%d' + postfix, string, uint],
                     result = prefix + string + middle + uint + postfix;
                 return result === $.PrivateBin.Helper.sprintf.apply(this, params);
@@ -209,15 +209,14 @@ describe('Helper', function () {
 
         jsc.property(
             'returns the requested cookie',
-            'nearray asciinestring',
-            'nearray asciistring',
+            jsc.nearray(jsc.nearray(common.jscAlnumString())),
+            jsc.nearray(jsc.nearray(common.jscAlnumString())),
             function (labels, values) {
                 var selectedKey = '', selectedValue = '',
                     cookieArray = [];
                 labels.forEach(function(item, i) {
-                    // deliberatly using a non-ascii key for replacing invalid characters
-                    var key = item.replace(/[\s;,=]/g, Array(i+2).join('£')),
-                        value = (values[i] || values[0]).replace(/[\s;,=]/g, '');
+                    var key = item.join(''),
+                        value = (values[i] || values[0]).join('');
                     cookieArray.push(key + '=' + value);
                     if (Math.random() < 1 / i || selectedKey === key)
                     {
@@ -227,6 +226,7 @@ describe('Helper', function () {
                 });
                 var clean = jsdom('', {cookie: cookieArray}),
                     result = $.PrivateBin.Helper.getCookie(selectedKey);
+                $.PrivateBin.Helper.reset();
                 clean();
                 return result === selectedValue;
             }
@@ -235,21 +235,19 @@ describe('Helper', function () {
 
     describe('baseUri', function () {
         this.timeout(30000);
-        before(function () {
-            $.PrivateBin.Helper.reset();
-        });
-
         jsc.property(
             'returns the URL without query & fragment',
-            common.jscSchemas(),
+            jsc.elements(['http', 'https']),
             jsc.nearray(common.jscA2zString()),
+            jsc.array(common.jscA2zString()),
             jsc.array(common.jscQueryString()),
             'string',
-            function (schema, address, query, fragment) {
-                var expected = schema + '://' + address.join('') + '/',
+            function (schema, address, path, query, fragment) {
+                $.PrivateBin.Helper.reset();
+                var path = path.join('') + (path.length > 0 ? '/' : ''),
+                    expected = schema + '://' + address.join('') + '/' + path,
                     clean = jsdom('', {url: expected + '?' + query.join('') + '#' + fragment}),
                     result = $.PrivateBin.Helper.baseUri();
-                $.PrivateBin.Helper.reset();
                 clean();
                 return expected === result;
             }

+ 108 - 9
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', '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 + '<a href="' + params[0] + '"></a>' + postfix, {
+                        ALLOWED_TAGS: ['a', 'i', 'span'],
+                        ALLOWED_ATTR: ['href', 'id']
+                    }
+                );
+                params.unshift(prefix + '<a href="%s"></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 + '<a href="' + params[0] + '"></a>' + postfix, {
+                        ALLOWED_TAGS: ['a', 'i', 'span'],
+                        ALLOWED_ATTR: ['href', 'id']
+                    }
+                );
+                let args = Array.prototype.slice.call(params);
+                args.unshift(prefix + '<a href="%s"></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 () {
@@ -88,14 +184,17 @@ describe('I18n', function () {
             'downloads and handles any supported language',
             common.jscSupportedLanguages(),
             function(language) {
-                var clean = jsdom('', {url: 'https://privatebin.net/', cookie: ['lang=' + language]});
-
+                // cleanup
+                var clean = jsdom('', {cookie: ['lang=en']});
                 $.PrivateBin.I18n.reset('en');
                 $.PrivateBin.I18n.loadTranslations();
+                clean();
+
+                // mock
+                clean = jsdom('', {cookie: ['lang=' + language]});
                 $.PrivateBin.I18n.reset(language, require('../../i18n/' + language + '.json'));
                 var result = $.PrivateBin.I18n.translate('en'),
                     alias  = $.PrivateBin.I18n._('en');
-
                 clean();
                 return language === result && language === alias;
             }

+ 1 - 4
js/test/PasteStatus.js

@@ -4,9 +4,6 @@ var common = require('../common');
 describe('PasteStatus', function () {
     describe('createPasteNotification', function () {
         this.timeout(30000);
-        before(function () {
-            cleanup();
-        });
 
         jsc.property(
             'creates a notification after a successfull paste upload',
@@ -24,7 +21,7 @@ describe('PasteStatus', function () {
                 var expected1 = schema1 + '://' + address1.join('') + '/?' +
                     encodeURI(query1.join('').replace(/^&+|&+$/gm,'') + '#' + fragment1),
                     expected2 = schema2 + '://' + address2.join('') + '/?' +
-                    encodeURI(query2.join('')),
+                    encodeURI(query2.join('').replace(/^&+|&+$/gm,'')),
                     clean = jsdom();
                 $('body').html('<div><div id="deletelink"></div><div id="pastelink"></div></div>');
                 $.PrivateBin.PasteStatus.init();

+ 1 - 1
tpl/bootstrap.php

@@ -75,7 +75,7 @@ if ($MARKDOWN):
 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/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-WMxduWsKcxVaSvyn4rTakNI+62QCAsrT9z67wR12yoLMCnLHV8JOVdisvjlpJNw5pWoMBmLcEpZkENq5/cVfDQ==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-0P2MKUF7ltPzb3r7M6Un13dcY+X+dTVB0N/5d0dubjlLyCJuMJZ0bNfWumCOjESPaY/d3T0lp1TPwar3qpAs8Q==" crossorigin="anonymous"></script>
 		<!--[if lt IE 10]>
 		<style type="text/css">body {padding-left:60px;padding-right:60px;} #ienotice {display:block;} #oldienotice {display:block;}</style>
 		<![endif]-->

+ 1 - 1
tpl/page.php

@@ -53,7 +53,7 @@ if ($MARKDOWN):
 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/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-WMxduWsKcxVaSvyn4rTakNI+62QCAsrT9z67wR12yoLMCnLHV8JOVdisvjlpJNw5pWoMBmLcEpZkENq5/cVfDQ==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-0P2MKUF7ltPzb3r7M6Un13dcY+X+dTVB0N/5d0dubjlLyCJuMJZ0bNfWumCOjESPaY/d3T0lp1TPwar3qpAs8Q==" crossorigin="anonymous"></script>
 		<!--[if lt IE 10]>
 		<style type="text/css">body {padding-left:60px;padding-right:60px;} #ienotice {display:block;} #oldienotice {display:block;}</style>
 		<![endif]-->