summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFelix Hammerl <fhammerl@thoughtworks.com>2016-02-24 10:13:25 +0100
committerFelix Hammerl <fhammerl@thoughtworks.com>2016-02-24 10:20:36 +0100
commit77ec41bb6f542077503106cacc1dbd28118c50b4 (patch)
tree7e59c1e5cdffd6146acb6504a9741d394af6a62f
parent6160633ab9a54238974af3cf498024ad98fc977e (diff)
Issue #617: Sanitize received content
Sanitizes received HTML content with DOMPurify, making it safe for displaying and templating. Sanitizes received plain text content by encoding every single character as HTML entity.
-rw-r--r--web-ui/app/js/helpers/sanitizer.js108
-rw-r--r--web-ui/app/js/helpers/view_helper.js37
-rw-r--r--web-ui/app/js/mail_view/ui/mail_view.js1
-rw-r--r--web-ui/app/js/main.js2
-rw-r--r--web-ui/bower.json4
-rw-r--r--web-ui/karma.conf.js2
-rw-r--r--web-ui/test/spec/helpers/sanitizer.spec.js49
-rw-r--r--web-ui/test/spec/helpers/view_helper.spec.js7
-rw-r--r--web-ui/test/test-main.js3
9 files changed, 171 insertions, 42 deletions
diff --git a/web-ui/app/js/helpers/sanitizer.js b/web-ui/app/js/helpers/sanitizer.js
new file mode 100644
index 00000000..eea1f0f7
--- /dev/null
+++ b/web-ui/app/js/helpers/sanitizer.js
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2016 ThoughtWorks, Inc.
+ *
+ * Pixelated is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Pixelated is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+define(['DOMPurify', 'he'], function (DOMPurify, he) {
+ 'use strict';
+
+ /**
+ * Sanitizes a mail body to safe-to-display HTML
+ */
+ var sanitizer = {};
+
+ /**
+ * Adds html line breaks to a plaintext with line breaks (incl carriage return)
+ *
+ * @param {string} textPlainBody Plaintext input
+ * @returns {string} Plaintext with HTML line breals (<br/>)
+ */
+ sanitizer.addLineBreaks = function (textPlainBody) {
+ return textPlainBody.replace(/(\r)?\n/g, '<br/>').replace(/(&#xD;)?&#xA;/g, '<br/>');
+ };
+
+ /**
+ * Runs a given dirty body through DOMPurify, thereby removing
+ * potentially hazardous XSS attacks. Please be advised that this
+ * will not act as a privacy leak prevention. Contained contents
+ * will still point to remote sources.
+ *
+ * For future reference: Running DOMPurify with these parameters
+ * can help mitigate some of the most widely used privacy leaks.
+ * FORBID_TAGS: ['style', 'svg', 'audio', 'video', 'math'],
+ * FORBID_ATTR: ['src']
+ *
+ * @param {string} dirtyBody The unsanitized string
+ * @return {string} Safe-to-display HTML string
+ */
+ sanitizer.purifyHtml = function (dirtyBody) {
+ return DOMPurify.sanitize(dirtyBody, {
+ SAFE_FOR_JQUERY: true,
+ SAFE_FOR_TEMPLATES: true
+ });
+ };
+
+ /**
+ * Runs a given dirty body through he, thereby encoding everything
+ * as HTML entities.
+ *
+ * @param {string} dirtyBody The unsanitized string
+ * @return {string} Safe-to-display HTML string
+ */
+ sanitizer.purifyText = function (dirtyBody) {
+ return he.encode(dirtyBody, {
+ encodeEverything: true
+ });
+ };
+
+ /**
+ * Calls #purify and #addLineBreaks to turn untrusted mail body content
+ * into safe-to-display HTML.
+ *
+ * NB: HTML content is preferred to plaintext content.
+ *
+ * @param {object} mail Pixelated Mail Object
+ * @return {string} Safe-to-display HTML string
+ */
+ sanitizer.sanitize = function (mail) {
+ var body;
+
+ if (mail.htmlBody) {
+ body = this.purifyHtml(mail.htmlBody);
+ } else {
+ body = this.purifyText(mail.textPlainBody);
+ body = this.addLineBreaks(body);
+ }
+
+ return body;
+ };
+
+ /**
+ * Add hooks to DOMPurify for opening links in new windows
+ */
+ DOMPurify.addHook('afterSanitizeAttributes', function (node) {
+ // set all elements owning target to target=_blank
+ if ('target' in node) {
+ node.setAttribute('target', '_blank');
+ }
+
+ // set non-HTML/MathML links to xlink:show=new
+ if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
+ node.setAttribute('xlink:show', 'new');
+ }
+ });
+
+ return sanitizer;
+});
diff --git a/web-ui/app/js/helpers/view_helper.js b/web-ui/app/js/helpers/view_helper.js
index e4e9277d..e8d517a5 100644
--- a/web-ui/app/js/helpers/view_helper.js
+++ b/web-ui/app/js/helpers/view_helper.js
@@ -17,12 +17,12 @@
define(
[
'helpers/contenttype',
- 'lib/html_whitelister',
'views/i18n',
'quoted-printable/quoted-printable',
- 'utf8/utf8'
+ 'utf8/utf8',
+ 'helpers/sanitizer'
],
- function(contentType, htmlWhitelister, i18n, quotedPrintable, utf8) {
+ function(contentType, i18n, quotedPrintable, utf8, sanitizer) {
'use strict';
function formatStatusClasses(ss) {
@@ -31,37 +31,8 @@ define(
}).join(' ');
}
- function addParagraphsToPlainText(textPlainBody) {
- return textPlainBody.replace(/^(.*?)$/mg, '$1<br/>');
- }
-
- function escapeHtmlTags(body) {
-
- var escapeIndex = {
- '&': '&amp;',
- '<': '&lt;',
- '>': '&gt;',
- '"': '&quot;',
- '\'':'&#39;',
- '/': '&#x2f;'
- };
-
- return body.replace(/["'<>\/&]/g, function(char){
- return escapeIndex[char];
- });
-
- }
-
- function escapeHtmlAndAddParagraphs(body) {
- var escapedBody = escapeHtmlTags(body);
- return addParagraphsToPlainText(escapedBody);
- }
-
function formatMailBody(mail) {
- var body = mail.htmlBody ?
- htmlWhitelister.sanitize(mail.htmlBody, htmlWhitelister.tagPolicy) :
- escapeHtmlAndAddParagraphs(mail.textPlainBody);
- return $('<div>' + body + '</div>');
+ return sanitizer.sanitize(mail);
}
function moveCaretToEnd(el) {
diff --git a/web-ui/app/js/mail_view/ui/mail_view.js b/web-ui/app/js/mail_view/ui/mail_view.js
index d4f5dd9e..8465b45a 100644
--- a/web-ui/app/js/mail_view/ui/mail_view.js
+++ b/web-ui/app/js/mail_view/ui/mail_view.js
@@ -72,6 +72,7 @@ define(
}));
this.$node.find('.bodyArea').html(viewHelpers.formatMailBody(data.mail));
+
this.trigger(document, events.search.highlightResults, {where: '.bodyArea'});
this.trigger(document, events.search.highlightResults, {where: '.subjectArea'});
this.trigger(document, events.search.highlightResults, {where: '.msg-header .recipients'});
diff --git a/web-ui/app/js/main.js b/web-ui/app/js/main.js
index 5fb2e46f..e093e790 100644
--- a/web-ui/app/js/main.js
+++ b/web-ui/app/js/main.js
@@ -22,6 +22,8 @@ requirejs.config({
'page': 'js/page',
'feedback': 'js/feedback',
'flight': 'bower_components/flight',
+ 'DOMPurify': 'bower_components/DOMPurify/dist/purify.min',
+ 'he': 'bower_components/he/he',
'hbs': 'js/generated/hbs',
'helpers': 'js/helpers',
'lib': 'js/lib',
diff --git a/web-ui/bower.json b/web-ui/bower.json
index 261f6e90..263ac2e4 100644
--- a/web-ui/bower.json
+++ b/web-ui/bower.json
@@ -15,7 +15,9 @@
"utf8": "~2.1.1",
"modernizr": "~2.8.3",
"jquery-file-upload": "~9.11.2",
- "jquery-ui": "~1.11.4"
+ "jquery-ui": "~1.11.4",
+ "DOMPurify": "~0.7.4",
+ "he": "~0.5.0"
},
"devDependencies": {
"handlebars": "2.0.0",
diff --git a/web-ui/karma.conf.js b/web-ui/karma.conf.js
index a59b1d4f..e31262ff 100644
--- a/web-ui/karma.conf.js
+++ b/web-ui/karma.conf.js
@@ -36,6 +36,8 @@ module.exports = function (config) {
'node_modules/karma-requirejs/lib/adapter.js',
// loaded with require
+ {pattern: 'app/bower_components/DOMPurify/dist/purify.min.js', included: false},
+ {pattern: 'app/bower_components/he/he.js', included: false},
{pattern: 'app/bower_components/flight/**/*.js', included: false},
{pattern: 'app/bower_components/i18next/**/*.js', included: false},
{pattern: 'app/bower_components/quoted-printable/*.js', included: false},
diff --git a/web-ui/test/spec/helpers/sanitizer.spec.js b/web-ui/test/spec/helpers/sanitizer.spec.js
new file mode 100644
index 00000000..acd4b2b2
--- /dev/null
+++ b/web-ui/test/spec/helpers/sanitizer.spec.js
@@ -0,0 +1,49 @@
+define(['helpers/sanitizer'], function (sanitizer) {
+ 'use strict';
+
+ describe('sanitizer', function () {
+
+ describe('sanitizer.addLineBreaks', function () {
+ it('should add line breaks', function () {
+ var expectedOutput = 'foo<br/>bar';
+ var output = sanitizer.addLineBreaks('foo\nbar');
+ expect(output).toEqual(expectedOutput);
+ });
+ });
+
+ describe('sanitizer.purifyHtml', function () {
+ it('should fire up DOMPurify', function () {
+ var expectedOutput = '123<a target="_blank">I am a dolphin!</a>';
+ var output = sanitizer.purifyHtml('123<a href="javascript:alert(1)">I am a dolphin!</a>');
+ expect(output).toEqual(expectedOutput);
+ });
+ });
+
+ describe('sanitizer.purifyText', function () {
+ it('should escape HTML', function () {
+ var expectedOutput = '&#x31;&#x32;&#x33;&#x3C;&#x61;&#x3E;&#x61;&#x73;&#x64;&#x3C;&#x2F;&#x61;&#x3E;';
+ var output = sanitizer.purifyText('123<a>asd</a>');
+ expect(output).toEqual(expectedOutput);
+ });
+ });
+
+ describe('sanitizer.sanitize', function () {
+ it('should sanitize a plaintext mail', function () {
+ var expectedOutput = '&#x31;&#x32;&#x33;&#x3C;&#x61;&#x3E;&#x61;&#x73;&#x64;&#x3C;&#x2F;&#x61;&#x3E;';
+ var output = sanitizer.sanitize({
+ textPlainBody: '123<a>asd</a>'
+ });
+ expect(output).toEqual(expectedOutput);
+ });
+
+ it('should sanitize an html mail', function () {
+ var expectedOutput = '<div>123<a target="_blank">I am a dolphin!</a>foobar</div>';
+ var output = sanitizer.sanitize({
+ htmlBody: '<div>123<a href="javascript:alert(1)">I am a dolphin!</a>foobar</div>'
+ });
+ expect(output).toEqual(expectedOutput);
+ });
+ });
+
+ });
+});
diff --git a/web-ui/test/spec/helpers/view_helper.spec.js b/web-ui/test/spec/helpers/view_helper.spec.js
index 92a31a1f..b2f597c2 100644
--- a/web-ui/test/spec/helpers/view_helper.spec.js
+++ b/web-ui/test/spec/helpers/view_helper.spec.js
@@ -90,13 +90,6 @@ define(['helpers/view_helper'], function (viewHelper) {
});
});
- it('each line of plain text mail gets a new paragraph', function () {
- var formattedMail = $('<div></div>');
- formattedMail.html(viewHelper.formatMailBody(testData.parsedMail.simpleTextPlain));
- expect(formattedMail).toContainHtml('<div>Hello Everyone<br/></div>');
- });
-
-
it('escape html in plain text body', function () {
var formattedMail = $('<div></div>');
var mail = testData.parsedMail.simpleTextPlain;
diff --git a/web-ui/test/test-main.js b/web-ui/test/test-main.js
index 7d87d9de..17ba3876 100644
--- a/web-ui/test/test-main.js
+++ b/web-ui/test/test-main.js
@@ -14,6 +14,8 @@ requirejs.config({
'lib': 'app/js/lib',
'hbs': 'app/js/generated/hbs',
'flight': 'app/bower_components/flight',
+ 'DOMPurify': 'app/bower_components/DOMPurify/dist/purify.min',
+ 'he': 'app/bower_components/he/he',
'views': 'app/js/views',
'helpers': 'app/js/helpers',
'feedback': 'app/js/feedback',
@@ -35,7 +37,6 @@ requirejs.config({
'user_settings': 'app/js/user_settings'
},
-
deps: tests,
callback: function () {