Przeglądaj źródła

implement simplified translation logic, forcing the use of safe application via jQuery element

El RIDO 6 lat temu
rodzic
commit
62365880b4
4 zmienionych plików z 122 dodań i 111 usunięć
  1. 49 31
      js/privatebin.js
  2. 71 78
      js/test/I18n.js
  3. 1 1
      tpl/bootstrap.php
  4. 1 1
      tpl/page.php

+ 49 - 31
js/privatebin.js

@@ -322,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;
@@ -547,19 +540,23 @@ jQuery.PrivateBin = (function($, RawDeflate) {
         /**
          * translate a string
          *
-         * Optionally pass a jQuery element as the first parameter, to automatically
-         * let the text of this element be replaced. In case the (asynchronously
+         * As the first parameter a jQuery element has to be provided, to 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.
+         * is replaced when it is actually loaded. This also handles HTML in
+         * secure fashion, to avoid XSS.
+         * The second 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
-         * @param  {jQuery} $element - optional
+         * @param  {jQuery} $element
          * @param  {string} messageId
          * @param  {...*} args - one or multiple parameters injected into placeholders
-         * @return {string}
+         * @throws {string}
          */
         me.translate = function()
         {
@@ -573,6 +570,8 @@ jQuery.PrivateBin = (function($, RawDeflate) {
                 // optional jQuery element as first parameter
                 $element = args[0];
                 args.shift();
+            } else {
+                throw 'translation requires a jQuery element to be passed, for secure insertion of messages and to avoid double encoding of HTML entities';
             }
 
             // extract messageId from arguments
@@ -633,10 +632,10 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             let containsLinks = args[0].indexOf('<a') !== -1;
 
             // prevent double encoding, when we insert into a text node
-            if (!containsLinks || $element === null) {
+            if (!containsLinks) {
                 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) {
+                    if (i > 0) {
                         args[i] = Helper.htmlEntities(args[i]);
                     }
                 }
@@ -654,18 +653,37 @@ jQuery.PrivateBin = (function($, RawDeflate) {
                 );
             }
 
-            // if $element is given, insert translation
-            if ($element !== null) {
-                if (containsLinks) {
-                    $element.html(output);
-                } else {
-                    // text node takes care of entity encoding
-                    $element.text(output);                    
-                }
-                return '';
+            if (containsLinks) {
+                $element.html(output);
+            } else {
+                // text node takes care of entity encoding
+                $element.text(output);
             }
+        };
 
-            return output;
+        /**
+         * translate a string, outputs the result
+         *
+         * This function is identical to I18n.translate, but doesn't require a
+         * jQuery element as the first parameter, instead it returns the
+         * translated message as string.
+         * Avoid using this function, if possible, as it may double encode your
+         * message's HTML entities. This is done to fail safe, preventing XSS.
+         *
+         * @name   I18n.translate2string
+         * @function
+         * @param  {string} messageId
+         * @param  {...*} args - one or multiple parameters injected into placeholders
+         * @throws {string}
+         * @return {string}
+         */
+        me.translate2string = function()
+        {
+            let args = Array.prototype.slice.call(arguments),
+                $element = $('<textarea>');
+            args.unshift($element);
+            me.translate.apply(this, args);
+            return $element.text();
         };
 
         /**

+ 71 - 78
js/test/I18n.js

@@ -8,78 +8,6 @@ describe('I18n', function () {
             $.PrivateBin.I18n.reset();
         });
 
-        jsc.property(
-            'returns message ID unchanged if no translation found',
-            'string',
-            function (messageId) {
-                messageId   = messageId.replace(/%(s|d)/g, '%%');
-                var plurals = [messageId, messageId + 's'],
-                    fake    = [messageId],
-                    result  = $.PrivateBin.I18n.translate(messageId);
-                $.PrivateBin.I18n.reset();
-
-                var alias = $.PrivateBin.I18n._(messageId);
-                $.PrivateBin.I18n.reset();
-
-                var pluralResult = $.PrivateBin.I18n.translate(plurals);
-                $.PrivateBin.I18n.reset();
-
-                var pluralAlias = $.PrivateBin.I18n._(plurals);
-                $.PrivateBin.I18n.reset();
-
-                var fakeResult = $.PrivateBin.I18n.translate(fake);
-                $.PrivateBin.I18n.reset();
-
-                var fakeAlias = $.PrivateBin.I18n._(fake);
-                $.PrivateBin.I18n.reset();
-
-                messageId = $.PrivateBin.Helper.htmlEntities(messageId);
-                return messageId === result && messageId === alias &&
-                    messageId === pluralResult && messageId === pluralAlias &&
-                    messageId === fakeResult && messageId === fakeAlias;
-            }
-        );
-        jsc.property(
-            '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, '%%');
-                params[0] = params[0].replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                postfix   =   postfix.replace(/%(s|d)/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',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%');
-                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();
-                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',
@@ -144,6 +72,73 @@ describe('I18n', function () {
         );
     });
 
+    describe('translate2string', function () {
+        this.timeout(30000);
+        before(function () {
+            $.PrivateBin.I18n.reset();
+        });
+
+        jsc.property(
+            'returns message ID unchanged if no translation found',
+            'string',
+            function (messageId) {
+                messageId   = messageId.replace(/%(s|d)/g, '%%');
+                let plurals = [messageId, messageId + 's'],
+                    fake    = [messageId],
+                    clean   = jsdom(),
+                    result  = $.PrivateBin.I18n.translate2string(messageId);
+                $.PrivateBin.I18n.reset();
+
+                var pluralResult = $.PrivateBin.I18n.translate2string(plurals);
+                $.PrivateBin.I18n.reset();
+
+                var fakeResult = $.PrivateBin.I18n.translate2string(fake);
+                $.PrivateBin.I18n.reset();
+
+                clean();
+                messageId = $.PrivateBin.Helper.htmlEntities(messageId);
+                return messageId === result && messageId === pluralResult && messageId === fakeResult;
+            }
+        );
+        jsc.property(
+            '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, '%%');
+                params[0] = params[0].replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                const translation = $.PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
+                params.unshift(prefix + '%s' + postfix);
+                const result = $.PrivateBin.I18n.translate2string.apply(this, params);
+                $.PrivateBin.I18n.reset();
+                return translation === result;
+            }
+        );
+        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',
+            function (prefix, params, postfix) {
+                prefix    =    prefix.replace(/%(s|d)/g, '%%');
+                params[0] = params[0].replace(/%(s|d)/g, '%%');
+                postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                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.translate2string.apply(this, params);
+                $.PrivateBin.I18n.reset();
+                return translation === result;
+            }
+        );
+    });
+
     describe('getPluralForm', function () {
         before(function () {
             $.PrivateBin.I18n.reset();
@@ -183,10 +178,9 @@ describe('I18n', function () {
                 // mock
                 clean = jsdom('', {cookie: ['lang=' + language]});
                 $.PrivateBin.I18n.reset(language, require('../../i18n/' + language + '.json'));
-                var result = $.PrivateBin.I18n.translate('en'),
-                    alias  = $.PrivateBin.I18n._('en');
+                var result = $.PrivateBin.I18n.translate2string('en');
                 clean();
-                return language === result && language === alias;
+                return language === result;
             }
         );
 
@@ -206,11 +200,10 @@ describe('I18n', function () {
 
                 $.PrivateBin.I18n.reset('en');
                 $.PrivateBin.I18n.loadTranslations();
-                var result = $.PrivateBin.I18n.translate('en'),
-                    alias  = $.PrivateBin.I18n._('en');
+                var result = $.PrivateBin.I18n.translate2string('en');
 
                 clean();
-                return 'en' === result && 'en' === alias;
+                return 'en' === result;
             }
         );
     });

+ 1 - 1
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-U9Au7V0FSY8S1xI6MrhPawEOFAPFejMI8PYlQNhC++XIQCQgQhYEqTYkhczN6F2MFAq/P1Hwn9A3IWaq9hu95g==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-Pss+4+Yrpp5ZROFKS9VWpX13RUpVp2QXDNZtFzNrr7YV65D+iKKUeE1z/Sff887+3fyOyIhJwKMsdfnmXnaNkQ==" 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" />

+ 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-U9Au7V0FSY8S1xI6MrhPawEOFAPFejMI8PYlQNhC++XIQCQgQhYEqTYkhczN6F2MFAq/P1Hwn9A3IWaq9hu95g==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-Pss+4+Yrpp5ZROFKS9VWpX13RUpVp2QXDNZtFzNrr7YV65D+iKKUeE1z/Sff887+3fyOyIhJwKMsdfnmXnaNkQ==" 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" />