diff --git a/js/common.js b/js/common.js index a13a6daa..b153dfc6 100644 --- a/js/common.js +++ b/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'], diff --git a/js/privatebin.js b/js/privatebin.js index c84b027f..c1b016b6 100644 --- a/js/privatebin.js +++ b/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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' + }; + /** * 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 { - '"': '"', - "'": ''', - '/': '/' - }[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(' 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(' 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 %s (Hit [Ctrl]+[c] to copy)', - shortUrl, shortUrl - ) + I18n._( + $('#pastelink'), + 'Your paste is %s (Hit [Ctrl]+[c] to copy)', + 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 %s (Hit [Ctrl]+[c] to copy)', - url, url - ) + I18n._( + $('#pastelink'), + 'Your paste is %s (Hit [Ctrl]+[c] to copy)', + url, url ); // save newly created element $pasteUrl = $('#pasteurl'); @@ -1947,7 +1961,8 @@ jQuery.PrivateBin = (function($, RawDeflate) { $pasteUrl.click(pasteLinkClick); // delete link - $('#deletelink').html('' + I18n._('Delete data') + ''); + $('#deletelink').html(''); + 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.'); } } diff --git a/js/test/AttachmentViewer.js b/js/test/AttachmentViewer.js index f3b3bb57..0cf89a30 100644 --- a/js/test/AttachmentViewer.js +++ b/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('').text((prefix + filename + postfix)).text(); } else { - result = $('
').html(prefix + $.PrivateBin.Helper.htmlEntities(filename) + postfix).html(); + result = prefix + $.PrivateBin.Helper.htmlEntities(filename) + postfix; } if (filename.length) { results.push( diff --git a/js/test/I18n.js b/js/test/I18n.js index 9bfc76ac..6d82b63b 100644 --- a/js/test/I18n.js +++ b/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('' + postfix, { + ALLOWED_TAGS: ['a', 'br', 'i', 'span'], + ALLOWED_ATTR: ['href', 'id'] + } + ); + params.unshift(prefix + '%s' + 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(/').text((prefix + params[0] + postfix)).text(); + let args = Array.prototype.slice.call(params); + args.unshift(prefix + '%s' + postfix); + let clean = jsdom(); + $('body').html('
'); + args.unshift($('#i18n')); + $.PrivateBin.I18n.translate.apply(this, args); + const result = $('#i18n').text(); + $.PrivateBin.I18n.reset(); + clean(); + clean = jsdom(); + $('body').html('
'); + 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]) + '
' + postfix, { + ALLOWED_TAGS: ['a', 'br', 'i', 'span'], + ALLOWED_ATTR: ['href', 'id'] + } + ); + let args = Array.prototype.slice.call(params); + args.unshift(prefix + '%s' + postfix); + let clean = jsdom(); + $('body').html('
'); + args.unshift($('#i18n')); + $.PrivateBin.I18n.translate.apply(this, args); + const result = $('#i18n').html(); + $.PrivateBin.I18n.reset(); + clean(); + clean = jsdom(); + $('body').html('
'); + args[0] = $('#i18n'); + $.PrivateBin.I18n._.apply(this, args); + const alias = $('#i18n').html(); + $.PrivateBin.I18n.reset(); + clean(); + return translation === result && translation === alias; + } + ); }); describe('getPluralForm', function () { diff --git a/tpl/bootstrap.php b/tpl/bootstrap.php index cd952eea..e7d5980e 100644 --- a/tpl/bootstrap.php +++ b/tpl/bootstrap.php @@ -72,7 +72,7 @@ endif; ?> - + @@ -517,8 +517,8 @@ endif; ?>