summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJon Newson <jon_newson@ieee.org>2016-02-26 16:20:59 +1100
committerJon Newson <jon_newson@ieee.org>2016-02-26 16:20:59 +1100
commit05f4e2ca2d64eaba23c87df4d2e2cc9e09bba6de (patch)
tree50b2ccf6454f31a3f6bceaa997a5e2abbcb91a80
parent52467b9aef76c9aac2f250478befd3afb7b6aabd (diff)
parentdbb434b56e6b161a3b851ae6a81f96dff14a29da (diff)
Merge branch 'master' of https://github.com/pixelated/pixelated-user-agent
# By Felix Hammerl (5) and others # Via NavaL * 'master' of https://github.com/pixelated/pixelated-user-agent: serving the client directly, as the current dependency on proxy strips out xsrf cookies -fixing functional test only adding feature resource in root_resource test -- fixing build changed logout to post Issue #612 Backend and frontend protection against csrf attacks: - root resources changes the csrf token cookie everytime it is loaded, in particular during the intestitial load during login - it will also add that cookie on single user mode - initialize will still load all resources - but they you cant access them if the csrf token do not match - all ajax calls needs to add the token to the header - non ajax get requests do not need xsrf token validation - non ajax post will have to send the token in as a form input or in the content Consolidate stylesheets Remove unused font and stylesheetgit s Create a new deferred for all IMAPAccount calls Clean up jshintrc Recreate session on soledad problems issue #617: Remove old html whitelister Issue #617: Sanitize received content
-rw-r--r--service/pixelated/bitmask_libraries/session.py24
-rw-r--r--service/pixelated/config/leap.py31
-rw-r--r--service/pixelated/resources/__init__.py4
-rw-r--r--service/pixelated/resources/auth.py1
-rw-r--r--service/pixelated/resources/login_resource.py3
-rw-r--r--service/pixelated/resources/logout_resource.py2
-rw-r--r--service/pixelated/resources/root_resource.py69
-rw-r--r--service/test/functional/features/environment.py6
-rw-r--r--service/test/integration/test_logout.py6
-rw-r--r--service/test/integration/test_retrieve_attachment.py17
-rw-r--r--service/test/support/integration/app_test_client.py23
-rw-r--r--service/test/support/integration/multi_user_client.py9
-rw-r--r--service/test/support/test_helper.py13
-rw-r--r--service/test/unit/bitmask_libraries/test_session.py12
-rw-r--r--service/test/unit/config/test_leap.py38
-rw-r--r--service/test/unit/resources/test_logout_resources.py8
-rw-r--r--service/test/unit/resources/test_root_resource.py83
-rw-r--r--web-ui/.jshintrc15
-rw-r--r--web-ui/app/fonts/NewsCycleBold.ttfbin73912 -> 0 bytes
-rw-r--r--web-ui/app/fonts/NewsCycleRegular.ttfbin193996 -> 0 bytes
-rw-r--r--web-ui/app/index.html11
-rw-r--r--web-ui/app/js/helpers/browser.js9
-rw-r--r--web-ui/app/js/helpers/contenttype.js2
-rw-r--r--web-ui/app/js/helpers/monitored_ajax.js10
-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/lib/html-sanitizer.js1064
-rw-r--r--web-ui/app/js/lib/html_whitelister.js86
-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/app/js/page/default.js6
-rw-r--r--web-ui/app/js/page/logout.js13
-rw-r--r--web-ui/app/js/services/mail_service.js2
-rw-r--r--web-ui/app/scss/_mascot.scss2
-rw-r--r--web-ui/app/scss/_styles.scss (renamed from web-ui/app/scss/styles.scss)17
-rw-r--r--web-ui/app/scss/main.scss8
-rw-r--r--web-ui/app/scss/news-cycle.scss13
-rw-r--r--web-ui/app/templates/page/logout.hbs7
-rw-r--r--web-ui/bower.json4
-rw-r--r--web-ui/karma.conf.js2
-rw-r--r--web-ui/test/spec/helpers/browser.spec.js12
-rw-r--r--web-ui/test/spec/helpers/monitored_ajax_call.spec.js24
-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/spec/mail_view/ui/draft_box.spec.js2
-rw-r--r--web-ui/test/spec/mail_view/ui/draft_save_status.spec.js2
-rw-r--r--web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js2
-rw-r--r--web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js2
-rw-r--r--web-ui/test/spec/mail_view/ui/send_button.spec.js2
-rw-r--r--web-ui/test/spec/page/logout.spec.js53
-rw-r--r--web-ui/test/spec/page/router/url_params.spec.js2
-rw-r--r--web-ui/test/spec/tags/data/tags.spec.js2
-rw-r--r--web-ui/test/spec/user_alerts/ui/user_alerts.spec.js2
-rw-r--r--web-ui/test/test-main.js3
54 files changed, 604 insertions, 1328 deletions
diff --git a/service/pixelated/bitmask_libraries/session.py b/service/pixelated/bitmask_libraries/session.py
index f28d9f59..ae3eb992 100644
--- a/service/pixelated/bitmask_libraries/session.py
+++ b/service/pixelated/bitmask_libraries/session.py
@@ -53,18 +53,19 @@ class LeapSession(object):
self.fresh_account = False
self.incoming_mail_fetcher = None
self.account = None
- self._has_been_synced = False
+ self._has_been_initially_synced = False
self._sem_intial_sync = defer.DeferredLock()
+ self._is_closed = False
register(events.KEYMANAGER_FINISHED_KEY_GENERATION, self._set_fresh_account, uid=self.account_email())
@defer.inlineCallbacks
def initial_sync(self):
yield self._sem_intial_sync.acquire()
try:
- if not self._has_been_synced:
- yield self.sync()
+ yield self.sync()
+ if not self._has_been_initially_synced:
yield self.after_first_sync()
- self._has_been_synced = True
+ self._has_been_initially_synced = True
finally:
yield self._sem_intial_sync.release()
defer.returnValue(self)
@@ -81,8 +82,7 @@ class LeapSession(object):
reactor.callFromThread(self.incoming_mail_fetcher.startService)
def _create_account(self, user_mail, soledad):
- account = IMAPAccount(user_mail, soledad)
- return account
+ return IMAPAccount(user_mail, soledad, defer.Deferred())
def _set_fresh_account(self, event, email_address):
log.debug('Key for email %s has been generated' % email_address)
@@ -94,12 +94,17 @@ class LeapSession(object):
return self.provider.address_for(name)
def close(self):
+ self._is_closed = True
self.stop_background_jobs()
unregister(events.KEYMANAGER_FINISHED_KEY_GENERATION, uid=self.account_email())
self.soledad.close()
self.remove_from_cache()
self._close_account()
+ @property
+ def is_closed(self):
+ return self._is_closed
+
def _close_account(self):
if self.account:
self.account.end_session()
@@ -284,7 +289,12 @@ class SessionCache(object):
@staticmethod
def lookup_session(key):
- return SessionCache.sessions.get(key, None)
+ session = SessionCache.sessions.get(key, None)
+ if session is not None and session.is_closed:
+ SessionCache.remove_session(key)
+ return None
+ else:
+ return session
@staticmethod
def remember_session(key, session):
diff --git a/service/pixelated/config/leap.py b/service/pixelated/config/leap.py
index a8666086..17a69406 100644
--- a/service/pixelated/config/leap.py
+++ b/service/pixelated/config/leap.py
@@ -1,14 +1,13 @@
from __future__ import absolute_import
-from leap.common.events import (server as events_server,
- register, catalog as events)
+from leap.common.events import (server as events_server)
+from leap.soledad.common.errors import InvalidAuthTokenError
+
from pixelated.config import credentials
from pixelated.bitmask_libraries.config import LeapConfig
from pixelated.bitmask_libraries.certs import LeapCertificate
from pixelated.bitmask_libraries.provider import LeapProvider
from pixelated.bitmask_libraries.session import LeapSessionFactory
from twisted.internet import defer
-import os
-import logging
import logging
log = logging.getLogger(__name__)
@@ -39,11 +38,29 @@ def initialize_leap_multi_user(provider_hostname,
defer.returnValue((config, provider))
+def _create_session(provider, username, password, auth):
+ return LeapSessionFactory(provider).create(username, password, auth)
+
+
+def _force_close_session(session):
+ try:
+ session.close()
+ except Exception, e:
+ log.error(e)
+
+
@defer.inlineCallbacks
def authenticate_user(provider, username, password, initial_sync=True, auth=None):
- leap_session = LeapSessionFactory(provider).create(username, password, auth)
- if initial_sync:
- yield leap_session.initial_sync()
+ leap_session = _create_session(provider, username, password, auth)
+ try:
+ if initial_sync:
+ yield leap_session.initial_sync()
+ except InvalidAuthTokenError:
+ _force_close_session(leap_session)
+
+ leap_session = _create_session(provider, username, password, auth)
+ if initial_sync:
+ yield leap_session.initial_sync()
defer.returnValue(leap_session)
diff --git a/service/pixelated/resources/__init__.py b/service/pixelated/resources/__init__.py
index 14ecac86..469c8bc8 100644
--- a/service/pixelated/resources/__init__.py
+++ b/service/pixelated/resources/__init__.py
@@ -99,3 +99,7 @@ class UnAuthorizedResource(Resource):
def render_GET(self, request):
request.setResponseCode(UNAUTHORIZED)
return "Unauthorized!"
+
+ def render_POST(self, request):
+ request.setResponseCode(UNAUTHORIZED)
+ return "Unauthorized!"
diff --git a/service/pixelated/resources/auth.py b/service/pixelated/resources/auth.py
index 02729a01..a6ab5396 100644
--- a/service/pixelated/resources/auth.py
+++ b/service/pixelated/resources/auth.py
@@ -41,7 +41,6 @@ log = logging.getLogger(__name__)
class LeapPasswordChecker(object):
credentialInterfaces = (
credentials.IUsernamePassword,
- credentials.IUsernameHashedPassword
)
def __init__(self, leap_provider):
diff --git a/service/pixelated/resources/login_resource.py b/service/pixelated/resources/login_resource.py
index f1d9c1e3..aca266cf 100644
--- a/service/pixelated/resources/login_resource.py
+++ b/service/pixelated/resources/login_resource.py
@@ -20,14 +20,13 @@ from xml.sax import SAXParseException
from twisted.cred import credentials
from twisted.internet import defer
+from twisted.python.filepath import FilePath
from twisted.web import util
-from twisted.web.error import FlattenerError
from twisted.web.http import UNAUTHORIZED, OK
from twisted.web.resource import IResource, NoResource
from twisted.web.server import NOT_DONE_YET
from twisted.web.static import File
from twisted.web.template import Element, XMLFile, renderElement, renderer
-from twisted.python.filepath import FilePath
from pixelated.adapter.welcome_mail import add_welcome_mail
from pixelated.resources import BaseResource, UnAuthorizedResource, IPixelatedSession
diff --git a/service/pixelated/resources/logout_resource.py b/service/pixelated/resources/logout_resource.py
index fe80316e..344ad2e9 100644
--- a/service/pixelated/resources/logout_resource.py
+++ b/service/pixelated/resources/logout_resource.py
@@ -8,7 +8,7 @@ class LogoutResource(BaseResource):
BASE_URL = "logout"
isLeaf = True
- def render_GET(self, request):
+ def render_POST(self, request):
session = self.get_session(request)
self._services_factory.log_out_user(session.user_uuid)
session.expire()
diff --git a/service/pixelated/resources/root_resource.py b/service/pixelated/resources/root_resource.py
index 6e619951..86435d89 100644
--- a/service/pixelated/resources/root_resource.py
+++ b/service/pixelated/resources/root_resource.py
@@ -13,11 +13,12 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
-
+import hashlib
+import json
import os
from string import Template
-from pixelated.resources import BaseResource
+from pixelated.resources import BaseResource, UnAuthorizedResource
from pixelated.resources.attachments_resource import AttachmentsResource
from pixelated.resources.contacts_resource import ContactsResource
from pixelated.resources.features_resource import FeaturesResource
@@ -29,22 +30,22 @@ from pixelated.resources.mail_resource import MailResource
from pixelated.resources.mails_resource import MailsResource
from pixelated.resources.tags_resource import TagsResource
from pixelated.resources.keys_resource import KeysResource
-from twisted.web.resource import Resource
from twisted.web.static import File
+CSRF_TOKEN_LENGTH = 32
MODE_STARTUP = 1
MODE_RUNNING = 2
class RootResource(BaseResource):
-
def __init__(self, services_factory):
BaseResource.__init__(self, services_factory)
self._startup_assets_folder = self._get_startup_folder()
self._static_folder = self._get_static_folder()
self._html_template = open(os.path.join(self._static_folder, 'index.html')).read()
self._services_factory = services_factory
+ self._child_resources = ChildResourcesMap()
self._startup_mode()
def _startup_mode(self):
@@ -54,21 +55,39 @@ class RootResource(BaseResource):
def getChild(self, path, request):
if path == '':
return self
- return Resource.getChild(self, path, request)
+ if self._is_xsrf_valid(request):
+ return self._child_resources.get(path)
+ return UnAuthorizedResource()
+
+ def _is_xsrf_valid(self, request):
+ xsrf_token = request.getCookie('XSRF-TOKEN')
+
+ ajax_request = (request.getHeader('x-requested-with') == 'XMLHttpRequest')
+ if ajax_request:
+ xsrf_header = request.getHeader('x-xsrf-token')
+ return xsrf_header and xsrf_header == xsrf_token
+
+ get_request = (request.method == 'GET')
+ if get_request:
+ return True
+
+ csrf_input = request.args.get('csrftoken', [None])[0] or json.loads(request.content.read()).get('csrftoken', [None])[0]
+ return csrf_input and csrf_input == xsrf_token
def initialize(self, portal=None, disclaimer_banner=None):
- self.putChild('assets', File(self._static_folder))
- self.putChild('keys', KeysResource(self._services_factory))
- self.putChild(AttachmentsResource.BASE_URL, AttachmentsResource(self._services_factory))
- self.putChild('contacts', ContactsResource(self._services_factory))
- self.putChild('features', FeaturesResource(portal))
- self.putChild('tags', TagsResource(self._services_factory))
- self.putChild('mails', MailsResource(self._services_factory))
- self.putChild('mail', MailResource(self._services_factory))
- self.putChild('feedback', FeedbackResource(self._services_factory))
- self.putChild('user-settings', UserSettingsResource(self._services_factory))
- self.putChild(LoginResource.BASE_URL, LoginResource(self._services_factory, portal, disclaimer_banner=disclaimer_banner))
- self.putChild(LogoutResource.BASE_URL, LogoutResource(self._services_factory))
+ self._child_resources.add('assets', File(self._static_folder))
+ self._child_resources.add('keys', KeysResource(self._services_factory))
+ self._child_resources.add(AttachmentsResource.BASE_URL, AttachmentsResource(self._services_factory))
+ self._child_resources.add('contacts', ContactsResource(self._services_factory))
+ self._child_resources.add('features', FeaturesResource(portal))
+ self._child_resources.add('tags', TagsResource(self._services_factory))
+ self._child_resources.add('mails', MailsResource(self._services_factory))
+ self._child_resources.add('mail', MailResource(self._services_factory))
+ self._child_resources.add('feedback', FeedbackResource(self._services_factory))
+ self._child_resources.add('user-settings', UserSettingsResource(self._services_factory))
+ self._child_resources.add(LoginResource.BASE_URL,
+ LoginResource(self._services_factory, portal, disclaimer_banner=disclaimer_banner))
+ self._child_resources.add(LogoutResource.BASE_URL, LogoutResource(self._services_factory))
self._mode = MODE_RUNNING
@@ -89,10 +108,26 @@ class RootResource(BaseResource):
def _is_starting(self):
return self._mode == MODE_STARTUP
+ def _add_csrf_cookie(self, request):
+ csrf_token = hashlib.sha256(os.urandom(CSRF_TOKEN_LENGTH)).hexdigest()
+ request.addCookie('XSRF-TOKEN', csrf_token)
+
def render_GET(self, request):
+ self._add_csrf_cookie(request)
if self._is_starting():
return open(os.path.join(self._startup_assets_folder, 'Interstitial.html')).read()
else:
account_email = self.mail_service(request).account_email
response = Template(self._html_template).safe_substitute(account_email=account_email)
return str(response)
+
+
+class ChildResourcesMap(object):
+ def __init__(self):
+ self._registry = {}
+
+ def add(self, path, resource):
+ self._registry[path] = resource
+
+ def get(self, path):
+ return self._registry.get(path)
diff --git a/service/test/functional/features/environment.py b/service/test/functional/features/environment.py
index 37b5d612..2c07faf3 100644
--- a/service/test/functional/features/environment.py
+++ b/service/test/functional/features/environment.py
@@ -44,11 +44,9 @@ def before_all(context):
PixelatedSite.disable_csp_requests()
client = AppTestClient()
start_app_test_client(client, UserAgentMode(is_single_user=True))
- client.listenTCP()
- proxy = Proxy(proxy_port='8889', app_port='4567')
+ client.listenTCP(port=8889)
FeaturesResource.DISABLED_FEATURES.append('autoRefresh')
context.client = client
- context.call_to_terminate_proxy = proxy.run_on_a_thread()
multi_user_client = AppTestClient()
start_app_test_client(multi_user_client, UserAgentMode(is_single_user=False))
@@ -57,7 +55,7 @@ def before_all(context):
def after_all(context):
- context.call_to_terminate_proxy()
+ context.client.stop()
def before_feature(context, feature):
diff --git a/service/test/integration/test_logout.py b/service/test/integration/test_logout.py
index 52f7e34f..da414126 100644
--- a/service/test/integration/test_logout.py
+++ b/service/test/integration/test_logout.py
@@ -13,10 +13,11 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+import json
+
from mockito import verify
from twisted.internet import defer
-from test.support.integration import load_mail_from_file
from test.support.integration.multi_user_client import MultiUserClient
from test.support.integration.soledad_test_base import SoledadTestBase
@@ -34,7 +35,8 @@ class MultiUserLogoutTest(MultiUserClient, SoledadTestBase):
yield self.wait_for_session_user_id_to_finish()
- response, request = self.get("/logout", as_json=False, from_request=login_request)
+ response, request = self.post("/logout", json.dumps({'csrftoken': [login_request.getCookie('XSRF-TOKEN')]}),
+ from_request=login_request, as_json=False)
yield response
self.assertEqual(302, request.responseCode) # redirected
diff --git a/service/test/integration/test_retrieve_attachment.py b/service/test/integration/test_retrieve_attachment.py
index 4aaeadc2..31c8c5df 100644
--- a/service/test/integration/test_retrieve_attachment.py
+++ b/service/test/integration/test_retrieve_attachment.py
@@ -43,6 +43,23 @@ class RetrieveAttachmentTest(SoledadTestBase):
self.assertEquals(expected_content_disposition, req.outgoingHeaders['content-disposition'])
self.assertEquals(expected_content_type, req.outgoingHeaders['content-type'])
+ @defer.inlineCallbacks
+ def test_should_retrieve_attachment_even_if_xsrf_token_not_passed(self):
+ attachment_id, input_mail = self._create_mail_with_attachment()
+ yield self.mail_store.add_mail('INBOX', input_mail.as_string())
+
+ requested_filename = "file name with space"
+ expected_content_type = 'text/plain'
+ expected_content_disposition = 'attachment; filename="file name with space"'
+
+ attachment, req = yield self.get_attachment(attachment_id, 'base64', filename=requested_filename,
+ content_type=expected_content_type, ajax=False, csrf='mismatched token')
+
+ self.assertEqual(200, req.code)
+ self.assertEquals('pretend to be binary attachment data', attachment)
+ self.assertEquals(expected_content_disposition, req.outgoingHeaders['content-disposition'])
+ self.assertEquals(expected_content_type, req.outgoingHeaders['content-type'])
+
def _create_mail_with_attachment(self):
input_mail = MIMEMultipart()
input_mail.attach(MIMEText(u'a utf8 message', _charset='utf-8'))
diff --git a/service/test/support/integration/app_test_client.py b/service/test/support/integration/app_test_client.py
index 8ab58397..f3ec5d25 100644
--- a/service/test/support/integration/app_test_client.py
+++ b/service/test/support/integration/app_test_client.py
@@ -244,22 +244,25 @@ class AppTestClient(object):
time.sleep(1)
return lambda: process.terminate()
- def get(self, path, get_args='', as_json=True):
- request = request_mock(path)
+ def stop(self):
+ reactor.stop()
+
+ def get(self, path, get_args='', as_json=True, ajax=True, csrf='token'):
+ request = request_mock(path, ajax=ajax, csrf=csrf)
request.args = get_args
return self._render(request, as_json)
- def post(self, path, body='', headers=None):
+ def post(self, path, body='', headers=None, ajax=True, csrf='token'):
headers = headers or {'Content-Type': 'application/json'}
- request = request_mock(path=path, method="POST", body=body, headers=headers)
+ request = request_mock(path=path, method="POST", body=body, headers=headers, ajax=ajax, csrf=csrf)
return self._render(request)
- def put(self, path, body):
- request = request_mock(path=path, method="PUT", body=body, headers={'Content-Type': ['application/json']})
+ def put(self, path, body, ajax=True, csrf='token'):
+ request = request_mock(path=path, method="PUT", body=body, headers={'Content-Type': ['application/json']}, ajax=ajax, csrf=csrf)
return self._render(request)
- def delete(self, path, body=""):
- request = request_mock(path=path, body=body, headers={'Content-Type': ['application/json']}, method="DELETE")
+ def delete(self, path, body="", ajax=True, csrf='token'):
+ request = request_mock(path=path, body=body, headers={'Content-Type': ['application/json']}, method="DELETE", ajax=ajax, csrf=csrf)
return self._render(request)
@defer.inlineCallbacks
@@ -322,13 +325,13 @@ class AppTestClient(object):
defer.returnValue(mails)
@defer.inlineCallbacks
- def get_attachment(self, ident, encoding, filename=None, content_type=None):
+ def get_attachment(self, ident, encoding, filename=None, content_type=None, ajax=True, csrf='token'):
params = {'encoding': [encoding]}
if filename:
params['filename'] = [filename]
if content_type:
params['content_type'] = [content_type]
- deferred_result, req = self.get("/attachment/%s" % ident, params, as_json=False)
+ deferred_result, req = self.get("/attachment/%s" % ident, params, as_json=False, ajax=ajax, csrf=csrf)
res = yield deferred_result
defer.returnValue((res, req))
diff --git a/service/test/support/integration/multi_user_client.py b/service/test/support/integration/multi_user_client.py
index fa65fb06..5f24456b 100644
--- a/service/test/support/integration/multi_user_client.py
+++ b/service/test/support/integration/multi_user_client.py
@@ -82,3 +82,12 @@ class MultiUserClient(AppTestClient):
session = from_request.getSession()
request.session = session
return self._render(request, as_json)
+
+ def post(self, path, body='', headers=None, ajax=True, csrf='token', as_json=True, from_request=None):
+ headers = headers or {'Content-Type': 'application/json'}
+ request = request_mock(path=path, method="POST", body=body, headers=headers, ajax=ajax, csrf=csrf)
+
+ if from_request:
+ session = from_request.getSession()
+ request.session = session
+ return self._render(request, as_json)
diff --git a/service/test/support/test_helper.py b/service/test/support/test_helper.py
index 77c74407..640baf6f 100644
--- a/service/test/support/test_helper.py
+++ b/service/test/support/test_helper.py
@@ -88,6 +88,7 @@ class PixRequestMock(DummyRequest):
DummyRequest.__init__(self, path)
self.content = None
self.code = None
+ self.cookies = {}
def getWrittenData(self):
if len(self.written):
@@ -97,8 +98,14 @@ class PixRequestMock(DummyRequest):
self.setResponseCode(302)
self.setHeader(b"location", url)
+ def addCookie(self, key, value):
+ self.cookies[key] = value
-def request_mock(path='', method='GET', body='', headers={}):
+ def getCookie(self, key):
+ return self.cookies.get(key)
+
+
+def request_mock(path='', method='GET', body='', headers={}, ajax=True, csrf='token'):
dummy = PixRequestMock(path.split('/'))
for name, val in headers.iteritems():
dummy.headers[name.lower()] = val
@@ -108,5 +115,9 @@ def request_mock(path='', method='GET', body='', headers={}):
else:
for key, val in body.items():
dummy.addArg(key, val)
+ if ajax:
+ dummy.headers['x-requested-with'] = 'XMLHttpRequest'
+ dummy.headers['x-xsrf-token'] = csrf
+ dummy.addCookie('XSRF-TOKEN', csrf)
return dummy
diff --git a/service/test/unit/bitmask_libraries/test_session.py b/service/test/unit/bitmask_libraries/test_session.py
index a41cb805..aad2cac2 100644
--- a/service/test/unit/bitmask_libraries/test_session.py
+++ b/service/test/unit/bitmask_libraries/test_session.py
@@ -133,6 +133,16 @@ class SessionTest(AbstractLeapTest):
self.assertTrue(session.fresh_account)
@patch('pixelated.bitmask_libraries.session.register')
+ def test_closed_session_not_reused(self, _):
+ session = self._create_session()
+ SessionCache.remember_session('somekey', session)
+ session._is_closed = True
+
+ result = SessionCache.lookup_session('somekey')
+
+ self.assertIsNone(result)
+
+ @patch('pixelated.bitmask_libraries.session.register')
def test_session_does_not_set_status_fresh_for_unkown_emails(self, _):
session = self._create_session()
self.provider.address_for.return_value = 'someone@somedomain.tld'
@@ -150,7 +160,7 @@ class SessionTest(AbstractLeapTest):
with patch('pixelated.bitmask_libraries.session.reactor.callFromThread', new=_execute_func) as _:
with patch.object(LeapSession, '_create_incoming_mail_fetcher', return_value=mailFetcherMock) as _:
session = self._create_session()
- session._has_been_synced = True
+ session._has_been_initially_synced = True
yield session.initial_sync()
self.assertFalse(mailFetcherMock.startService.called)
diff --git a/service/test/unit/config/test_leap.py b/service/test/unit/config/test_leap.py
new file mode 100644
index 00000000..6b34d717
--- /dev/null
+++ b/service/test/unit/config/test_leap.py
@@ -0,0 +1,38 @@
+from leap.soledad.common.errors import InvalidAuthTokenError
+from mock import MagicMock, patch
+from twisted.trial import unittest
+from twisted.internet import defer
+from pixelated.config.leap import authenticate_user
+
+
+class TestAuth(unittest.TestCase):
+
+ @patch('pixelated.config.leap.LeapSessionFactory')
+ @defer.inlineCallbacks
+ def test_authenticate_user_calls_initinal_sync(self, session_factory__ctor_mock):
+ session_factory_mock = session_factory__ctor_mock.return_value
+ provider_mock = MagicMock()
+ auth_mock = MagicMock()
+ session = MagicMock()
+
+ session_factory_mock.create.return_value = session
+
+ yield authenticate_user(provider_mock, 'username', 'password', auth=auth_mock)
+
+ session.initial_sync.assert_called_with()
+
+ @patch('pixelated.config.leap.LeapSessionFactory')
+ @defer.inlineCallbacks
+ def test_authenticate_user_calls_initial_sync_a_second_time_if_invalid_auth_exception_is_raised(self, session_factory__ctor_mock):
+ session_factory_mock = session_factory__ctor_mock.return_value
+ provider_mock = MagicMock()
+ auth_mock = MagicMock()
+ session = MagicMock()
+
+ session.initial_sync.side_effect = [InvalidAuthTokenError, defer.succeed(None)]
+ session_factory_mock.create.return_value = session
+
+ yield authenticate_user(provider_mock, 'username', 'password', auth=auth_mock)
+
+ session.close.assert_called_with()
+ self.assertEqual(2, session.initial_sync.call_count)
diff --git a/service/test/unit/resources/test_logout_resources.py b/service/test/unit/resources/test_logout_resources.py
index 48cf9db9..6246eeb9 100644
--- a/service/test/unit/resources/test_logout_resources.py
+++ b/service/test/unit/resources/test_logout_resources.py
@@ -1,6 +1,7 @@
from mock import patch
from mockito import mock, verify
from twisted.trial import unittest
+from twisted.web.error import UnsupportedMethod
from twisted.web.test.requesthelper import DummyRequest
from pixelated.resources.logout_resource import LogoutResource
@@ -16,6 +17,7 @@ class TestLogoutResource(unittest.TestCase):
@patch('twisted.web.util.redirectTo')
def test_logout(self, mock_redirect):
request = DummyRequest(['/logout'])
+ request.method = 'POST'
mock_redirect.return_value = 'haha'
@@ -29,3 +31,9 @@ class TestLogoutResource(unittest.TestCase):
d.addCallback(expire_session_and_redirect)
return d
+
+ def test_get_is_not_supported_for_logout(self):
+ request = DummyRequest(['/logout'])
+ request.method = 'GET'
+
+ self.assertRaises(UnsupportedMethod, self.web.get, request)
diff --git a/service/test/unit/resources/test_root_resource.py b/service/test/unit/resources/test_root_resource.py
index 3b0846ee..cc052d8b 100644
--- a/service/test/unit/resources/test_root_resource.py
+++ b/service/test/unit/resources/test_root_resource.py
@@ -1,11 +1,14 @@
import unittest
import re
+
+from mock import MagicMock, patch
from mockito import mock, when, any as ANY
from pixelated.application import UserAgentMode
+from pixelated.resources.features_resource import FeaturesResource
from test.unit.resources import DummySite
from twisted.web.test.requesthelper import DummyRequest
-from pixelated.resources.root_resource import RootResource
+from pixelated.resources.root_resource import RootResource, MODE_STARTUP, MODE_RUNNING
class TestRootResource(unittest.TestCase):
@@ -25,9 +28,11 @@ class TestRootResource(unittest.TestCase):
root_resource._html_template = "<html><head><title>$account_email</title></head></html>"
root_resource._mode = root_resource
self.web = DummySite(root_resource)
+ self.root_resource = root_resource
def test_render_GET_should_template_account_email(self):
request = DummyRequest([''])
+ request.addCookie = lambda key, value: 'stubbed'
d = self.web.get(request)
@@ -38,3 +43,79 @@ class TestRootResource(unittest.TestCase):
d.addCallback(assert_response)
return d
+
+ def _test_should_renew_xsrf_cookie(self):
+ request = DummyRequest([''])
+ request.addCookie = MagicMock()
+ generated_csrf_token = 'csrf_token'
+ mock_sha = MagicMock()
+ mock_sha.hexdigest = MagicMock(return_value=generated_csrf_token)
+
+ with patch('hashlib.sha256', return_value=mock_sha):
+ d = self.web.get(request)
+
+ def assert_csrf_cookie(_):
+ request.addCookie.assert_called_once_with('XSRF-TOKEN', generated_csrf_token)
+
+ d.addCallback(assert_csrf_cookie)
+ return d
+
+ def test_should_renew_xsrf_cookie_on_startup_mode(self):
+ self.root_resource._mode = MODE_STARTUP
+ self._test_should_renew_xsrf_cookie()
+
+ def test_should_renew_xsrf_cookie_on_running_mode(self):
+ self.root_resource._mode = MODE_RUNNING
+ self._test_should_renew_xsrf_cookie()
+
+ def _mock_ajax_csrf(self, request, csrf_token):
+ request.headers['x-requested-with'] = 'XMLHttpRequest'
+ request.headers['x-xsrf-token'] = csrf_token
+
+ def test_should_unauthorize_child_resource_ajax_requests_when_csrf_mismatch(self):
+ request = DummyRequest(['/child'])
+ self._mock_ajax_csrf(request, 'stubbed csrf token')
+
+ request.getCookie = MagicMock(return_value='mismatched csrf token')
+
+ d = self.web.get(request)
+
+ def assert_unauthorized(_):
+ self.assertEqual(401, request.responseCode)
+ self.assertEqual("Unauthorized!", request.written[0])
+
+ d.addCallback(assert_unauthorized)
+ return d
+
+ def test_should_authorize_child_resource_non_ajax_GET_requests(self):
+ request = DummyRequest(['features'])
+
+ request.getCookie = MagicMock(return_value='irrelevant -- stubbed')
+ self.root_resource._child_resources.add('features', FeaturesResource())
+
+ d = self.web.get(request)
+
+ def assert_unauthorized(_):
+ self.assertEqual(200, request.code)
+
+ d.addCallback(assert_unauthorized)
+ return d
+
+ def test_should_unauthorize_child_resource_non_ajax_POST_requests_when_csrf_input_mismatch(self):
+ request = DummyRequest(['mails'])
+ request.method = 'POST'
+ request.addArg('csrftoken', 'some csrf token')
+ mock_content = MagicMock()
+ mock_content.read = MagicMock(return_value={})
+ request.content = mock_content
+
+ request.getCookie = MagicMock(return_value='mismatched csrf token')
+
+ d = self.web.get(request)
+
+ def assert_unauthorized(_):
+ self.assertEqual(401, request.responseCode)
+ self.assertEqual("Unauthorized!", request.written[0])
+
+ d.addCallback(assert_unauthorized)
+ return d
diff --git a/web-ui/.jshintrc b/web-ui/.jshintrc
index 220aedfe..1e2a147d 100644
--- a/web-ui/.jshintrc
+++ b/web-ui/.jshintrc
@@ -1,29 +1,30 @@
{
- "node": true,
"browser": true,
"esnext": true,
"bitwise": true,
- "camelcase": false,
- "curly": false,
+ "curly": true,
"loopfunc": true,
"eqeqeq": true,
"immed": true,
"indent": 2,
- "latedef": false,
"newcap": true,
"noarg": true,
- "quotmark": "single",
"regexp": true,
"smarttabs": true,
- "strict": "function",
+ "strict": true,
"trailing": true,
- "undef": false,
+ "undef": true,
"validthis": true,
"jasmine": true,
+ "node": false,
+ "latedef": false,
"predef": [
+ "require",
+ "console",
"$",
"jQuery",
"define",
+ "Foundation",
"describeComponent",
"describeMixin",
"requirejs",
diff --git a/web-ui/app/fonts/NewsCycleBold.ttf b/web-ui/app/fonts/NewsCycleBold.ttf
deleted file mode 100644
index 8265217f..00000000
--- a/web-ui/app/fonts/NewsCycleBold.ttf
+++ /dev/null
Binary files differ
diff --git a/web-ui/app/fonts/NewsCycleRegular.ttf b/web-ui/app/fonts/NewsCycleRegular.ttf
deleted file mode 100644
index 9fbfd346..00000000
--- a/web-ui/app/fonts/NewsCycleRegular.ttf
+++ /dev/null
Binary files differ
diff --git a/web-ui/app/index.html b/web-ui/app/index.html
index 9ffeee82..2d35662c 100644
--- a/web-ui/app/index.html
+++ b/web-ui/app/index.html
@@ -1,9 +1,7 @@
<!DOCTYPE html>
<html>
<head>
-<link rel="icon"
- type="image/png"
- href="assets/images/Favicon.png">
+<link rel="icon" type="image/png" href="assets/images/Favicon.png">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>$account_email - Pixelated Mail</title>
@@ -12,7 +10,6 @@
<link href="assets/bower_components/font-awesome/css/font-awesome.min.css" rel="stylesheet" type="text/css">
<link href="assets/bower_components/jquery-file-upload/css/jquery.fileupload.css" rel="stylesheet" type="text/css">
<link href="assets/css/opensans.css" rel="stylesheet" type="text/css">
-<link href="assets/css/news-cycle.css" rel="stylesheet" type="text/css"/>
<link rel="stylesheet" href="assets/css/main.css">
</head>
@@ -30,14 +27,14 @@
<path fill="#3E3B38" d="M214.9,363.1h-21.8v64.6h14.7v-24h7.1c12.7,0,22-7.3,22-20.8C237,369.7,227.4,363.1,214.9,363.1z M212,392
h-4.2v-17.1h4.2c5.9,0,11.3,2,11.3,8.6S217.9,392,212,392z"/>
<rect x="241.9" y="363.1" fill="#3E3B38" width="14.7" height="64.6"/>
- <polygon fill="#3E3B38" points="320.7,363.1 302.3,363.1 290.3,380.7 278.3,363.1 261,363.1 281.3,392.9 259.2,427.7 277.6,427.7
+ <polygon fill="#3E3B38" points="320.7,363.1 302.3,363.1 290.3,380.7 278.3,363.1 261,363.1 281.3,392.9 259.2,427.7 277.6,427.7
290.3,405.7 303.1,427.7 322.2,427.7 299.4,392.9 "/>
- <polygon fill="#3E3B38" points="324.6,427.7 361.6,427.7 361.6,414.7 339.3,414.7 339.3,401.8 360.6,401.8 360.6,388.8
+ <polygon fill="#3E3B38" points="324.6,427.7 361.6,427.7 361.6,414.7 339.3,414.7 339.3,401.8 360.6,401.8 360.6,388.8
339.3,388.8 339.3,376 361.6,376 361.6,363.1 324.6,363.1 "/>
<path fill="#3E3B38" d="M416.6,363.1l-20.8,51.7h-14.4v-51.7h-14.7v64.6h24h13h2.9l4.9-13H436l4.9,13h15.9l-26.2-64.6H416.6z
M416.2,401.8l7.1-18.8h0.2l7.1,18.8H416.2z"/>
<polygon fill="#3E3B38" points="444.1,376 459.5,376 459.5,427.7 474.2,427.7 474.2,376 489.6,376 489.6,363.1 444.1,363.1 "/>
- <polygon fill="#3E3B38" points="494.5,427.7 531.5,427.7 531.5,414.7 509.4,414.7 509.4,401.8 530.7,401.8 530.7,388.8
+ <polygon fill="#3E3B38" points="494.5,427.7 531.5,427.7 531.5,414.7 509.4,414.7 509.4,401.8 530.7,401.8 530.7,388.8
509.4,388.8 509.4,376 531.5,376 531.5,363.1 494.5,363.1 "/>
<path fill="#3E3b38" d="M553,363.1h-16.2v64.6H553c17.9,0,32.6-13.5,32.6-32.3C585.6,376.5,570.6,363.1,553,363.1z M553.5,414.5
h-2.2v-38.2h2.2c11,0,18.4,8.3,18.4,19.1C571.9,406.2,564.5,414.5,553.5,414.5z"/>
diff --git a/web-ui/app/js/helpers/browser.js b/web-ui/app/js/helpers/browser.js
index e5be6667..dacf2263 100644
--- a/web-ui/app/js/helpers/browser.js
+++ b/web-ui/app/js/helpers/browser.js
@@ -23,7 +23,14 @@ define([], function () {
window.location.replace(url);
}
+ function getCookie(name) {
+ var value = '; ' + document.cookie;
+ var parts = value.split('; ' + name + '=');
+ if (parts.length === 2) { return parts.pop().split(';').shift(); }
+ }
+
return {
- redirect: redirect
+ redirect: redirect,
+ getCookie: getCookie
};
});
diff --git a/web-ui/app/js/helpers/contenttype.js b/web-ui/app/js/helpers/contenttype.js
index 92b456e9..a1e5361a 100644
--- a/web-ui/app/js/helpers/contenttype.js
+++ b/web-ui/app/js/helpers/contenttype.js
@@ -14,6 +14,8 @@
* You should have received a copy of the GNU Affero General Public License
* along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
*/
+
+/* jshint curly: false */
define([], function () {
'use strict';
var exports = {};
diff --git a/web-ui/app/js/helpers/monitored_ajax.js b/web-ui/app/js/helpers/monitored_ajax.js
index 1cb720de..dc182d58 100644
--- a/web-ui/app/js/helpers/monitored_ajax.js
+++ b/web-ui/app/js/helpers/monitored_ajax.js
@@ -36,6 +36,8 @@ define(['page/events', 'views/i18n', 'helpers/browser'], function (events, i18n,
}
};
+ config.headers = {'X-XSRF-TOKEN': browser.getCookie('XSRF-TOKEN')};
+
var originalComplete = config.complete;
config.complete = function () {
if (originalComplete) {
@@ -46,15 +48,15 @@ define(['page/events', 'views/i18n', 'helpers/browser'], function (events, i18n,
return $.ajax(url, config).fail(function (xmlhttprequest, textstatus, message) {
if (!config.skipErrorMessage) {
var msg = (xmlhttprequest.responseJSON && xmlhttprequest.responseJSON.message) ||
- messages[textstatus] ||
- 'unexpected problem while talking to server';
- on.trigger(document, events.ui.userAlerts.displayMessage, {message: i18n(msg)});
+ messages[textstatus] ||
+ 'unexpected problem while talking to server';
+ on.trigger(document, events.ui.userAlerts.displayMessage, {message: i18n(msg), class: 'error'});
}
if (xmlhttprequest.status === 302) {
var redirectUrl = xmlhttprequest.getResponseHeader('Location');
browser.redirect(redirectUrl);
- }else if (xmlhttprequest.status === 401) {
+ } else if (xmlhttprequest.status === 401) {
browser.redirect('/');
}
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/lib/html-sanitizer.js b/web-ui/app/js/lib/html-sanitizer.js
deleted file mode 100644
index 80fb0041..00000000
--- a/web-ui/app/js/lib/html-sanitizer.js
+++ /dev/null
@@ -1,1064 +0,0 @@
-// Copyright (C) 2006 Google Inc.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-/**
- * @fileoverview
- * An HTML sanitizer that can satisfy a variety of security policies.
- *
- * <p>
- * The HTML sanitizer is built around a SAX parser and HTML element and
- * attributes schemas.
- *
- * If the cssparser is loaded, inline styles are sanitized using the
- * css property and value schemas. Else they are remove during
- * sanitization.
- *
- * If it exists, uses parseCssDeclarations, sanitizeCssProperty, cssSchema
- *
- * @author mikesamuel@gmail.com
- * @author jasvir@gmail.com
- * \@requires html4, URI
- * \@overrides window
- * \@provides html, html_sanitize
- */
-
-// The Turkish i seems to be a non-issue, but abort in case it is.
-if ('I'.toLowerCase() !== 'i') { throw 'I/i problem'; }
-
-/**
- * \@namespace
- */
-define(['lib/html4-defs'], function (html4) {
-var html = (function(html4) {
-
- // For closure compiler
- var parseCssDeclarations, sanitizeCssProperty, cssSchema;
- if ('undefined' !== typeof window) {
- parseCssDeclarations = window['parseCssDeclarations'];
- sanitizeCssProperty = window['sanitizeCssProperty'];
- cssSchema = window['cssSchema'];
- }
-
- // The keys of this object must be 'quoted' or JSCompiler will mangle them!
- // This is a partial list -- lookupEntity() uses the host browser's parser
- // (when available) to implement full entity lookup.
- // Note that entities are in general case-sensitive; the uppercase ones are
- // explicitly defined by HTML5 (presumably as compatibility).
- var ENTITIES = {
- 'lt': '<',
- 'LT': '<',
- 'gt': '>',
- 'GT': '>',
- 'amp': '&',
- 'AMP': '&',
- 'quot': '"',
- 'apos': '\'',
- 'nbsp': '\240'
- };
-
- // Patterns for types of entity/character reference names.
- var decimalEscapeRe = /^#(\d+)$/;
- var hexEscapeRe = /^#x([0-9A-Fa-f]+)$/;
- // contains every entity per http://www.w3.org/TR/2011/WD-html5-20110113/named-character-references.html
- var safeEntityNameRe = /^[A-Za-z][A-za-z0-9]+$/;
- // Used as a hook to invoke the browser's entity parsing. <textarea> is used
- // because its content is parsed for entities but not tags.
- // TODO(kpreid): This retrieval is a kludge and leads to silent loss of
- // functionality if the document isn't available.
- var entityLookupElement =
- ('undefined' !== typeof window && window['document'])
- ? window['document'].createElement('textarea') : null;
- /**
- * Decodes an HTML entity.
- *
- * {\@updoc
- * $ lookupEntity('lt')
- * # '<'
- * $ lookupEntity('GT')
- * # '>'
- * $ lookupEntity('amp')
- * # '&'
- * $ lookupEntity('nbsp')
- * # '\xA0'
- * $ lookupEntity('apos')
- * # "'"
- * $ lookupEntity('quot')
- * # '"'
- * $ lookupEntity('#xa')
- * # '\n'
- * $ lookupEntity('#10')
- * # '\n'
- * $ lookupEntity('#x0a')
- * # '\n'
- * $ lookupEntity('#010')
- * # '\n'
- * $ lookupEntity('#x00A')
- * # '\n'
- * $ lookupEntity('Pi') // Known failure
- * # '\u03A0'
- * $ lookupEntity('pi') // Known failure
- * # '\u03C0'
- * }
- *
- * @param {string} name the content between the '&' and the ';'.
- * @return {string} a single unicode code-point as a string.
- */
- function lookupEntity(name) {
- // TODO: entity lookup as specified by HTML5 actually depends on the
- // presence of the ";".
- if (ENTITIES.hasOwnProperty(name)) { return ENTITIES[name]; }
- var m = name.match(decimalEscapeRe);
- if (m) {
- return String.fromCharCode(parseInt(m[1], 10));
- } else if (!!(m = name.match(hexEscapeRe))) {
- return String.fromCharCode(parseInt(m[1], 16));
- } else if (entityLookupElement && safeEntityNameRe.test(name)) {
- entityLookupElement.innerHTML = '&' + name + ';';
- var text = entityLookupElement.textContent;
- ENTITIES[name] = text;
- return text;
- } else {
- return '&' + name + ';';
- }
- }
-
- function decodeOneEntity(_, name) {
- return lookupEntity(name);
- }
-
- var nulRe = /\0/g;
- function stripNULs(s) {
- return s.replace(nulRe, '');
- }
-
- var ENTITY_RE_1 = /&(#[0-9]+|#[xX][0-9A-Fa-f]+|\w+);/g;
- var ENTITY_RE_2 = /^(#[0-9]+|#[xX][0-9A-Fa-f]+|\w+);/;
- /**
- * The plain text of a chunk of HTML CDATA which possibly containing.
- *
- * {\@updoc
- * $ unescapeEntities('')
- * # ''
- * $ unescapeEntities('hello World!')
- * # 'hello World!'
- * $ unescapeEntities('1 &lt; 2 &amp;&AMP; 4 &gt; 3&#10;')
- * # '1 < 2 && 4 > 3\n'
- * $ unescapeEntities('&lt;&lt <- unfinished entity&gt;')
- * # '<&lt <- unfinished entity>'
- * $ unescapeEntities('/foo?bar=baz&copy=true') // & often unescaped in URLS
- * # '/foo?bar=baz&copy=true'
- * $ unescapeEntities('pi=&pi;&#x3c0;, Pi=&Pi;\u03A0') // FIXME: known failure
- * # 'pi=\u03C0\u03c0, Pi=\u03A0\u03A0'
- * }
- *
- * @param {string} s a chunk of HTML CDATA. It must not start or end inside
- * an HTML entity.
- */
- function unescapeEntities(s) {
- return s.replace(ENTITY_RE_1, decodeOneEntity);
- }
-
- var ampRe = /&/g;
- var looseAmpRe = /&([^a-z#]|#(?:[^0-9x]|x(?:[^0-9a-f]|$)|$)|$)/gi;
- var ltRe = /[<]/g;
- var gtRe = />/g;
- var quotRe = /\"/g;
-
- /**
- * Escapes HTML special characters in attribute values.
- *
- * {\@updoc
- * $ escapeAttrib('')
- * # ''
- * $ escapeAttrib('"<<&==&>>"') // Do not just escape the first occurrence.
- * # '&#34;&lt;&lt;&amp;&#61;&#61;&amp;&gt;&gt;&#34;'
- * $ escapeAttrib('Hello <World>!')
- * # 'Hello &lt;World&gt;!'
- * }
- */
- function escapeAttrib(s) {
- return ('' + s).replace(ampRe, '&amp;').replace(ltRe, '&lt;')
- .replace(gtRe, '&gt;').replace(quotRe, '&#34;');
- }
-
- /**
- * Escape entities in RCDATA that can be escaped without changing the meaning.
- * {\@updoc
- * $ normalizeRCData('1 < 2 &&amp; 3 > 4 &amp;& 5 &lt; 7&8')
- * # '1 &lt; 2 &amp;&amp; 3 &gt; 4 &amp;&amp; 5 &lt; 7&amp;8'
- * }
- */
- function normalizeRCData(rcdata) {
- return rcdata
- .replace(looseAmpRe, '&amp;$1')
- .replace(ltRe, '&lt;')
- .replace(gtRe, '&gt;');
- }
-
- // TODO(felix8a): validate sanitizer regexs against the HTML5 grammar at
- // http://www.whatwg.org/specs/web-apps/current-work/multipage/syntax.html
- // http://www.whatwg.org/specs/web-apps/current-work/multipage/parsing.html
- // http://www.whatwg.org/specs/web-apps/current-work/multipage/tokenization.html
- // http://www.whatwg.org/specs/web-apps/current-work/multipage/tree-construction.html
-
- // We initially split input so that potentially meaningful characters
- // like '<' and '>' are separate tokens, using a fast dumb process that
- // ignores quoting. Then we walk that token stream, and when we see a
- // '<' that's the start of a tag, we use ATTR_RE to extract tag
- // attributes from the next token. That token will never have a '>'
- // character. However, it might have an unbalanced quote character, and
- // when we see that, we combine additional tokens to balance the quote.
-
- var ATTR_RE = new RegExp(
- '^\\s*' +
- '([-.:\\w]+)' + // 1 = Attribute name
- '(?:' + (
- '\\s*(=)\\s*' + // 2 = Is there a value?
- '(' + ( // 3 = Attribute value
- // TODO(felix8a): maybe use backref to match quotes
- '(\")[^\"]*(\"|$)' + // 4, 5 = Double-quoted string
- '|' +
- '(\')[^\']*(\'|$)' + // 6, 7 = Single-quoted string
- '|' +
- // Positive lookahead to prevent interpretation of
- // <foo a= b=c> as <foo a='b=c'>
- // TODO(felix8a): might be able to drop this case
- '(?=[a-z][-\\w]*\\s*=)' +
- '|' +
- // Unquoted value that isn't an attribute name
- // (since we didn't match the positive lookahead above)
- '[^\"\'\\s]*' ) +
- ')' ) +
- ')?',
- 'i');
-
- // false on IE<=8, true on most other browsers
- var splitWillCapture = ('a,b'.split(/(,)/).length === 3);
-
- // bitmask for tags with special parsing, like <script> and <textarea>
- var EFLAGS_TEXT = html4.eflags['CDATA'] | html4.eflags['RCDATA'];
-
- /**
- * Given a SAX-like event handler, produce a function that feeds those
- * events and a parameter to the event handler.
- *
- * The event handler has the form:{@code
- * {
- * // Name is an upper-case HTML tag name. Attribs is an array of
- * // alternating upper-case attribute names, and attribute values. The
- * // attribs array is reused by the parser. Param is the value passed to
- * // the saxParser.
- * startTag: function (name, attribs, param) { ... },
- * endTag: function (name, param) { ... },
- * pcdata: function (text, param) { ... },
- * rcdata: function (text, param) { ... },
- * cdata: function (text, param) { ... },
- * startDoc: function (param) { ... },
- * endDoc: function (param) { ... }
- * }}
- *
- * @param {Object} handler a record containing event handlers.
- * @return {function(string, Object)} A function that takes a chunk of HTML
- * and a parameter. The parameter is passed on to the handler methods.
- */
- function makeSaxParser(handler) {
- // Accept quoted or unquoted keys (Closure compat)
- var hcopy = {
- cdata: handler.cdata || handler['cdata'],
- comment: handler.comment || handler['comment'],
- endDoc: handler.endDoc || handler['endDoc'],
- endTag: handler.endTag || handler['endTag'],
- pcdata: handler.pcdata || handler['pcdata'],
- rcdata: handler.rcdata || handler['rcdata'],
- startDoc: handler.startDoc || handler['startDoc'],
- startTag: handler.startTag || handler['startTag']
- };
- return function(htmlText, param) {
- return parse(htmlText, hcopy, param);
- };
- }
-
- // Parsing strategy is to split input into parts that might be lexically
- // meaningful (every ">" becomes a separate part), and then recombine
- // parts if we discover they're in a different context.
-
- // TODO(felix8a): Significant performance regressions from -legacy,
- // tested on
- // Chrome 18.0
- // Firefox 11.0
- // IE 6, 7, 8, 9
- // Opera 11.61
- // Safari 5.1.3
- // Many of these are unusual patterns that are linearly slower and still
- // pretty fast (eg 1ms to 5ms), so not necessarily worth fixing.
-
- // TODO(felix8a): "<script> && && && ... <\/script>" is slower on all
- // browsers. The hotspot is htmlSplit.
-
- // TODO(felix8a): "<p title='>>>>...'><\/p>" is slower on all browsers.
- // This is partly htmlSplit, but the hotspot is parseTagAndAttrs.
-
- // TODO(felix8a): "<a><\/a><a><\/a>..." is slower on IE9.
- // "<a>1<\/a><a>1<\/a>..." is faster, "<a><\/a>2<a><\/a>2..." is faster.
-
- // TODO(felix8a): "<p<p<p..." is slower on IE[6-8]
-
- var continuationMarker = {};
- function parse(htmlText, handler, param) {
- var m, p, tagName;
- var parts = htmlSplit(htmlText);
- var state = {
- noMoreGT: false,
- noMoreEndComments: false
- };
- parseCPS(handler, parts, 0, state, param);
- }
-
- function continuationMaker(h, parts, initial, state, param) {
- return function () {
- parseCPS(h, parts, initial, state, param);
- };
- }
-
- function parseCPS(h, parts, initial, state, param) {
- try {
- if (h.startDoc && initial == 0) { h.startDoc(param); }
- var m, p, tagName;
- for (var pos = initial, end = parts.length; pos < end;) {
- var current = parts[pos++];
- var next = parts[pos];
- switch (current) {
- case '&':
- if (ENTITY_RE_2.test(next)) {
- if (h.pcdata) {
- h.pcdata('&' + next, param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- pos++;
- } else {
- if (h.pcdata) { h.pcdata("&amp;", param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- }
- break;
- case '<\/':
- if ((m = /^([-\w:]+)[^\'\"]*/.exec(next))) {
- if (m[0].length === next.length && parts[pos + 1] === '>') {
- // fast case, no attribute parsing needed
- pos += 2;
- tagName = m[1].toLowerCase();
- if (h.endTag) {
- h.endTag(tagName, param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- } else {
- // slow case, need to parse attributes
- // TODO(felix8a): do we really care about misparsing this?
- pos = parseEndTag(
- parts, pos, h, param, continuationMarker, state);
- }
- } else {
- if (h.pcdata) {
- h.pcdata('&lt;/', param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- }
- break;
- case '<':
- if (m = /^([-\w:]+)\s*\/?/.exec(next)) {
- if (m[0].length === next.length && parts[pos + 1] === '>') {
- // fast case, no attribute parsing needed
- pos += 2;
- tagName = m[1].toLowerCase();
- if (h.startTag) {
- h.startTag(tagName, [], param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- // tags like <script> and <textarea> have special parsing
- var eflags = html4.ELEMENTS[tagName];
- if (eflags & EFLAGS_TEXT) {
- var tag = { name: tagName, next: pos, eflags: eflags };
- pos = parseText(
- parts, tag, h, param, continuationMarker, state);
- }
- } else {
- // slow case, need to parse attributes
- pos = parseStartTag(
- parts, pos, h, param, continuationMarker, state);
- }
- } else {
- if (h.pcdata) {
- h.pcdata('&lt;', param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- }
- break;
- case '<\!--':
- // The pathological case is n copies of '<\!--' without '-->', and
- // repeated failure to find '-->' is quadratic. We avoid that by
- // remembering when search for '-->' fails.
- if (!state.noMoreEndComments) {
- // A comment <\!--x--> is split into three tokens:
- // '<\!--', 'x--', '>'
- // We want to find the next '>' token that has a preceding '--'.
- // pos is at the 'x--'.
- for (p = pos + 1; p < end; p++) {
- if (parts[p] === '>' && /--$/.test(parts[p - 1])) { break; }
- }
- if (p < end) {
- if (h.comment) {
- var comment = parts.slice(pos, p).join('');
- h.comment(
- comment.substr(0, comment.length - 2), param,
- continuationMarker,
- continuationMaker(h, parts, p + 1, state, param));
- }
- pos = p + 1;
- } else {
- state.noMoreEndComments = true;
- }
- }
- if (state.noMoreEndComments) {
- if (h.pcdata) {
- h.pcdata('&lt;!--', param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- }
- break;
- case '<\!':
- if (!/^\w/.test(next)) {
- if (h.pcdata) {
- h.pcdata('&lt;!', param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- } else {
- // similar to noMoreEndComment logic
- if (!state.noMoreGT) {
- for (p = pos + 1; p < end; p++) {
- if (parts[p] === '>') { break; }
- }
- if (p < end) {
- pos = p + 1;
- } else {
- state.noMoreGT = true;
- }
- }
- if (state.noMoreGT) {
- if (h.pcdata) {
- h.pcdata('&lt;!', param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- }
- }
- break;
- case '<?':
- // similar to noMoreEndComment logic
- if (!state.noMoreGT) {
- for (p = pos + 1; p < end; p++) {
- if (parts[p] === '>') { break; }
- }
- if (p < end) {
- pos = p + 1;
- } else {
- state.noMoreGT = true;
- }
- }
- if (state.noMoreGT) {
- if (h.pcdata) {
- h.pcdata('&lt;?', param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- }
- break;
- case '>':
- if (h.pcdata) {
- h.pcdata("&gt;", param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- break;
- case '':
- break;
- default:
- if (h.pcdata) {
- h.pcdata(current, param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- break;
- }
- }
- if (h.endDoc) { h.endDoc(param); }
- } catch (e) {
- if (e !== continuationMarker) { throw e; }
- }
- }
-
- // Split str into parts for the html parser.
- function htmlSplit(str) {
- // can't hoist this out of the function because of the re.exec loop.
- var re = /(<\/|<\!--|<[!?]|[&<>])/g;
- str += '';
- if (splitWillCapture) {
- return str.split(re);
- } else {
- var parts = [];
- var lastPos = 0;
- var m;
- while ((m = re.exec(str)) !== null) {
- parts.push(str.substring(lastPos, m.index));
- parts.push(m[0]);
- lastPos = m.index + m[0].length;
- }
- parts.push(str.substring(lastPos));
- return parts;
- }
- }
-
- function parseEndTag(parts, pos, h, param, continuationMarker, state) {
- var tag = parseTagAndAttrs(parts, pos);
- // drop unclosed tags
- if (!tag) { return parts.length; }
- if (h.endTag) {
- h.endTag(tag.name, param, continuationMarker,
- continuationMaker(h, parts, pos, state, param));
- }
- return tag.next;
- }
-
- function parseStartTag(parts, pos, h, param, continuationMarker, state) {
- var tag = parseTagAndAttrs(parts, pos);
- // drop unclosed tags
- if (!tag) { return parts.length; }
- if (h.startTag) {
- h.startTag(tag.name, tag.attrs, param, continuationMarker,
- continuationMaker(h, parts, tag.next, state, param));
- }
- // tags like <script> and <textarea> have special parsing
- if (tag.eflags & EFLAGS_TEXT) {
- return parseText(parts, tag, h, param, continuationMarker, state);
- } else {
- return tag.next;
- }
- }
-
- var endTagRe = {};
-
- // Tags like <script> and <textarea> are flagged as CDATA or RCDATA,
- // which means everything is text until we see the correct closing tag.
- function parseText(parts, tag, h, param, continuationMarker, state) {
- var end = parts.length;
- if (!endTagRe.hasOwnProperty(tag.name)) {
- endTagRe[tag.name] = new RegExp('^' + tag.name + '(?:[\\s\\/]|$)', 'i');
- }
- var re = endTagRe[tag.name];
- var first = tag.next;
- var p = tag.next + 1;
- for (; p < end; p++) {
- if (parts[p - 1] === '<\/' && re.test(parts[p])) { break; }
- }
- if (p < end) { p -= 1; }
- var buf = parts.slice(first, p).join('');
- if (tag.eflags & html4.eflags['CDATA']) {
- if (h.cdata) {
- h.cdata(buf, param, continuationMarker,
- continuationMaker(h, parts, p, state, param));
- }
- } else if (tag.eflags & html4.eflags['RCDATA']) {
- if (h.rcdata) {
- h.rcdata(normalizeRCData(buf), param, continuationMarker,
- continuationMaker(h, parts, p, state, param));
- }
- } else {
- throw new Error('bug');
- }
- return p;
- }
-
- // at this point, parts[pos-1] is either "<" or "<\/".
- function parseTagAndAttrs(parts, pos) {
- var m = /^([-\w:]+)/.exec(parts[pos]);
- var tag = {};
- tag.name = m[1].toLowerCase();
- tag.eflags = html4.ELEMENTS[tag.name];
- var buf = parts[pos].substr(m[0].length);
- // Find the next '>'. We optimistically assume this '>' is not in a
- // quoted context, and further down we fix things up if it turns out to
- // be quoted.
- var p = pos + 1;
- var end = parts.length;
- for (; p < end; p++) {
- if (parts[p] === '>') { break; }
- buf += parts[p];
- }
- if (end <= p) { return void 0; }
- var attrs = [];
- while (buf !== '') {
- m = ATTR_RE.exec(buf);
- if (!m) {
- // No attribute found: skip garbage
- buf = buf.replace(/^[\s\S][^a-z\s]*/, '');
-
- } else if ((m[4] && !m[5]) || (m[6] && !m[7])) {
- // Unterminated quote: slurp to the next unquoted '>'
- var quote = m[4] || m[6];
- var sawQuote = false;
- var abuf = [buf, parts[p++]];
- for (; p < end; p++) {
- if (sawQuote) {
- if (parts[p] === '>') { break; }
- } else if (0 <= parts[p].indexOf(quote)) {
- sawQuote = true;
- }
- abuf.push(parts[p]);
- }
- // Slurp failed: lose the garbage
- if (end <= p) { break; }
- // Otherwise retry attribute parsing
- buf = abuf.join('');
- continue;
-
- } else {
- // We have an attribute
- var aName = m[1].toLowerCase();
- var aValue = m[2] ? decodeValue(m[3]) : '';
- attrs.push(aName, aValue);
- buf = buf.substr(m[0].length);
- }
- }
- tag.attrs = attrs;
- tag.next = p + 1;
- return tag;
- }
-
- function decodeValue(v) {
- var q = v.charCodeAt(0);
- if (q === 0x22 || q === 0x27) { // " or '
- v = v.substr(1, v.length - 2);
- }
- return unescapeEntities(stripNULs(v));
- }
-
- /**
- * Returns a function that strips unsafe tags and attributes from html.
- * @param {function(string, Array.<string>): ?Array.<string>} tagPolicy
- * A function that takes (tagName, attribs[]), where tagName is a key in
- * html4.ELEMENTS and attribs is an array of alternating attribute names
- * and values. It should return a record (as follows), or null to delete
- * the element. It's okay for tagPolicy to modify the attribs array,
- * but the same array is reused, so it should not be held between calls.
- * Record keys:
- * attribs: (required) Sanitized attributes array.
- * tagName: Replacement tag name.
- * @return {function(string, Array)} A function that sanitizes a string of
- * HTML and appends result strings to the second argument, an array.
- */
- function makeHtmlSanitizer(tagPolicy) {
- var stack;
- var ignoring;
- var emit = function (text, out) {
- if (!ignoring) { out.push(text); }
- };
- return makeSaxParser({
- 'startDoc': function(_) {
- stack = [];
- ignoring = false;
- },
- 'startTag': function(tagNameOrig, attribs, out) {
- if (ignoring) { return; }
- if (!html4.ELEMENTS.hasOwnProperty(tagNameOrig)) { return; }
- var eflagsOrig = html4.ELEMENTS[tagNameOrig];
- if (eflagsOrig & html4.eflags['FOLDABLE']) {
- return;
- }
-
- var decision = tagPolicy(tagNameOrig, attribs);
- if (!decision) {
- ignoring = !(eflagsOrig & html4.eflags['EMPTY']);
- return;
- } else if (typeof decision !== 'object') {
- throw new Error('tagPolicy did not return object (old API?)');
- }
- if ('attribs' in decision) {
- attribs = decision['attribs'];
- } else {
- throw new Error('tagPolicy gave no attribs');
- }
- var eflagsRep;
- var tagNameRep;
- if ('tagName' in decision) {
- tagNameRep = decision['tagName'];
- eflagsRep = html4.ELEMENTS[tagNameRep];
- } else {
- tagNameRep = tagNameOrig;
- eflagsRep = eflagsOrig;
- }
- // TODO(mikesamuel): relying on tagPolicy not to insert unsafe
- // attribute names.
-
- // If this is an optional-end-tag element and either this element or its
- // previous like sibling was rewritten, then insert a close tag to
- // preserve structure.
- if (eflagsOrig & html4.eflags['OPTIONAL_ENDTAG']) {
- var onStack = stack[stack.length - 1];
- if (onStack && onStack.orig === tagNameOrig &&
- (onStack.rep !== tagNameRep || tagNameOrig !== tagNameRep)) {
- out.push('<\/', onStack.rep, '>');
- }
- }
-
- if (!(eflagsOrig & html4.eflags['EMPTY'])) {
- stack.push({orig: tagNameOrig, rep: tagNameRep});
- }
-
- out.push('<', tagNameRep);
- for (var i = 0, n = attribs.length; i < n; i += 2) {
- var attribName = attribs[i],
- value = attribs[i + 1];
- if (value !== null && value !== void 0) {
- out.push(' ', attribName, '="', escapeAttrib(value), '"');
- }
- }
- out.push('>');
-
- if ((eflagsOrig & html4.eflags['EMPTY'])
- && !(eflagsRep & html4.eflags['EMPTY'])) {
- // replacement is non-empty, synthesize end tag
- out.push('<\/', tagNameRep, '>');
- }
- },
- 'endTag': function(tagName, out) {
- if (ignoring) {
- ignoring = false;
- return;
- }
- if (!html4.ELEMENTS.hasOwnProperty(tagName)) { return; }
- var eflags = html4.ELEMENTS[tagName];
- if (!(eflags & (html4.eflags['EMPTY'] | html4.eflags['FOLDABLE']))) {
- var index;
- if (eflags & html4.eflags['OPTIONAL_ENDTAG']) {
- for (index = stack.length; --index >= 0;) {
- var stackElOrigTag = stack[index].orig;
- if (stackElOrigTag === tagName) { break; }
- if (!(html4.ELEMENTS[stackElOrigTag] &
- html4.eflags['OPTIONAL_ENDTAG'])) {
- // Don't pop non optional end tags looking for a match.
- return;
- }
- }
- } else {
- for (index = stack.length; --index >= 0;) {
- if (stack[index].orig === tagName) { break; }
- }
- }
- if (index < 0) { return; } // Not opened.
- for (var i = stack.length; --i > index;) {
- var stackElRepTag = stack[i].rep;
- if (!(html4.ELEMENTS[stackElRepTag] &
- html4.eflags['OPTIONAL_ENDTAG'])) {
- out.push('<\/', stackElRepTag, '>');
- }
- }
- if (index < stack.length) {
- tagName = stack[index].rep;
- }
- stack.length = index;
- out.push('<\/', tagName, '>');
- }
- },
- 'pcdata': emit,
- 'rcdata': emit,
- 'cdata': emit,
- 'endDoc': function(out) {
- for (; stack.length; stack.length--) {
- out.push('<\/', stack[stack.length - 1].rep, '>');
- }
- }
- });
- }
-
- var ALLOWED_URI_SCHEMES = /^(?:https?|mailto)$/i;
-
- function safeUri(uri, effect, ltype, hints, naiveUriRewriter) {
- if (!naiveUriRewriter) { return null; }
- try {
- var parsed = URI.parse('' + uri);
- if (parsed) {
- if (!parsed.hasScheme() ||
- ALLOWED_URI_SCHEMES.test(parsed.getScheme())) {
- var safe = naiveUriRewriter(parsed, effect, ltype, hints);
- return safe ? safe.toString() : null;
- }
- }
- } catch (e) {
- return null;
- }
- return null;
- }
-
- function log(logger, tagName, attribName, oldValue, newValue) {
- if (!attribName) {
- logger(tagName + " removed", {
- change: "removed",
- tagName: tagName
- });
- }
- if (oldValue !== newValue) {
- var changed = "changed";
- if (oldValue && !newValue) {
- changed = "removed";
- } else if (!oldValue && newValue) {
- changed = "added";
- }
- logger(tagName + "." + attribName + " " + changed, {
- change: changed,
- tagName: tagName,
- attribName: attribName,
- oldValue: oldValue,
- newValue: newValue
- });
- }
- }
-
- function lookupAttribute(map, tagName, attribName) {
- var attribKey;
- attribKey = tagName + '::' + attribName;
- if (map.hasOwnProperty(attribKey)) {
- return map[attribKey];
- }
- attribKey = '*::' + attribName;
- if (map.hasOwnProperty(attribKey)) {
- return map[attribKey];
- }
- return void 0;
- }
- function getAttributeType(tagName, attribName) {
- return lookupAttribute(html4.ATTRIBS, tagName, attribName);
- }
- function getLoaderType(tagName, attribName) {
- return lookupAttribute(html4.LOADERTYPES, tagName, attribName);
- }
- function getUriEffect(tagName, attribName) {
- return lookupAttribute(html4.URIEFFECTS, tagName, attribName);
- }
-
- /**
- * Sanitizes attributes on an HTML tag.
- * @param {string} tagName An HTML tag name in lowercase.
- * @param {Array.<?string>} attribs An array of alternating names and values.
- * @param {?function(?string): ?string} opt_naiveUriRewriter A transform to
- * apply to URI attributes; it can return a new string value, or null to
- * delete the attribute. If unspecified, URI attributes are deleted.
- * @param {function(?string): ?string} opt_nmTokenPolicy A transform to apply
- * to attributes containing HTML names, element IDs, and space-separated
- * lists of classes; it can return a new string value, or null to delete
- * the attribute. If unspecified, these attributes are kept unchanged.
- * @return {Array.<?string>} The sanitized attributes as a list of alternating
- * names and values, where a null value means to omit the attribute.
- */
- function sanitizeAttribs(tagName, attribs,
- opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger) {
- // TODO(felix8a): it's obnoxious that domado duplicates much of this
- // TODO(felix8a): maybe consistently enforce constraints like target=
- for (var i = 0; i < attribs.length; i += 2) {
- var attribName = attribs[i];
- var value = attribs[i + 1];
- var oldValue = value;
- var atype = null, attribKey;
- if ((attribKey = tagName + '::' + attribName,
- html4.ATTRIBS.hasOwnProperty(attribKey)) ||
- (attribKey = '*::' + attribName,
- html4.ATTRIBS.hasOwnProperty(attribKey))) {
- atype = html4.ATTRIBS[attribKey];
- }
- if (atype !== null) {
- switch (atype) {
- case html4.atype['NONE']: break;
- case html4.atype['SCRIPT']:
- value = null;
- if (opt_logger) {
- log(opt_logger, tagName, attribName, oldValue, value);
- }
- break;
- case html4.atype['STYLE']:
- if ('undefined' === typeof parseCssDeclarations) {
- value = null;
- if (opt_logger) {
- log(opt_logger, tagName, attribName, oldValue, value);
- }
- break;
- }
- var sanitizedDeclarations = [];
- parseCssDeclarations(
- value,
- {
- 'declaration': function (property, tokens) {
- var normProp = property.toLowerCase();
- sanitizeCssProperty(
- normProp, tokens,
- opt_naiveUriRewriter
- ? function (url) {
- return safeUri(
- url, html4.ueffects.SAME_DOCUMENT,
- html4.ltypes.SANDBOXED,
- {
- "TYPE": "CSS",
- "CSS_PROP": normProp
- }, opt_naiveUriRewriter);
- }
- : null);
- if (tokens.length) {
- sanitizedDeclarations.push(
- normProp + ': ' + tokens.join(' '));
- }
- }
- });
- value = sanitizedDeclarations.length > 0 ?
- sanitizedDeclarations.join(' ; ') : null;
- if (opt_logger) {
- log(opt_logger, tagName, attribName, oldValue, value);
- }
- break;
- case html4.atype['ID']:
- case html4.atype['IDREF']:
- case html4.atype['IDREFS']:
- case html4.atype['GLOBAL_NAME']:
- case html4.atype['LOCAL_NAME']:
- case html4.atype['CLASSES']:
- value = opt_nmTokenPolicy ? opt_nmTokenPolicy(value) : value;
- if (opt_logger) {
- log(opt_logger, tagName, attribName, oldValue, value);
- }
- break;
- case html4.atype['URI']:
- value = safeUri(value,
- getUriEffect(tagName, attribName),
- getLoaderType(tagName, attribName),
- {
- "TYPE": "MARKUP",
- "XML_ATTR": attribName,
- "XML_TAG": tagName
- }, opt_naiveUriRewriter);
- if (opt_logger) {
- log(opt_logger, tagName, attribName, oldValue, value);
- }
- break;
- case html4.atype['URI_FRAGMENT']:
- if (value && '#' === value.charAt(0)) {
- value = value.substring(1); // remove the leading '#'
- value = opt_nmTokenPolicy ? opt_nmTokenPolicy(value) : value;
- if (value !== null && value !== void 0) {
- value = '#' + value; // restore the leading '#'
- }
- } else {
- value = null;
- }
- if (opt_logger) {
- log(opt_logger, tagName, attribName, oldValue, value);
- }
- break;
- default:
- value = null;
- if (opt_logger) {
- log(opt_logger, tagName, attribName, oldValue, value);
- }
- break;
- }
- } else {
- value = null;
- if (opt_logger) {
- log(opt_logger, tagName, attribName, oldValue, value);
- }
- }
- attribs[i + 1] = value;
- }
- return attribs;
- }
-
- /**
- * Creates a tag policy that omits all tags marked UNSAFE in html4-defs.js
- * and applies the default attribute sanitizer with the supplied policy for
- * URI attributes and NMTOKEN attributes.
- * @param {?function(?string): ?string} opt_naiveUriRewriter A transform to
- * apply to URI attributes. If not given, URI attributes are deleted.
- * @param {function(?string): ?string} opt_nmTokenPolicy A transform to apply
- * to attributes containing HTML names, element IDs, and space-separated
- * lists of classes. If not given, such attributes are left unchanged.
- * @return {function(string, Array.<?string>)} A tagPolicy suitable for
- * passing to html.sanitize.
- */
- function makeTagPolicy(
- opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger) {
- return function(tagName, attribs) {
- if (!(html4.ELEMENTS[tagName] & html4.eflags['UNSAFE'])) {
- return {
- 'attribs': sanitizeAttribs(tagName, attribs,
- opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger)
- };
- } else {
- if (opt_logger) {
- log(opt_logger, tagName, undefined, undefined, undefined);
- }
- }
- };
- }
-
- /**
- * Sanitizes HTML tags and attributes according to a given policy.
- * @param {string} inputHtml The HTML to sanitize.
- * @param {function(string, Array.<?string>)} tagPolicy A function that
- * decides which tags to accept and sanitizes their attributes (see
- * makeHtmlSanitizer above for details).
- * @return {string} The sanitized HTML.
- */
- function sanitizeWithPolicy(inputHtml, tagPolicy) {
- var outputArray = [];
- makeHtmlSanitizer(tagPolicy)(inputHtml, outputArray);
- return outputArray.join('');
- }
-
- /**
- * Strips unsafe tags and attributes from HTML.
- * @param {string} inputHtml The HTML to sanitize.
- * @param {?function(?string): ?string} opt_naiveUriRewriter A transform to
- * apply to URI attributes. If not given, URI attributes are deleted.
- * @param {function(?string): ?string} opt_nmTokenPolicy A transform to apply
- * to attributes containing HTML names, element IDs, and space-separated
- * lists of classes. If not given, such attributes are left unchanged.
- */
- function sanitize(inputHtml,
- opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger) {
- var tagPolicy = makeTagPolicy(
- opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger);
- return sanitizeWithPolicy(inputHtml, tagPolicy);
- }
-
- // Export both quoted and unquoted names for Closure linkage.
- var html = {};
- html.escapeAttrib = html['escapeAttrib'] = escapeAttrib;
- html.makeHtmlSanitizer = html['makeHtmlSanitizer'] = makeHtmlSanitizer;
- html.makeSaxParser = html['makeSaxParser'] = makeSaxParser;
- html.makeTagPolicy = html['makeTagPolicy'] = makeTagPolicy;
- html.normalizeRCData = html['normalizeRCData'] = normalizeRCData;
- html.sanitize = html['sanitize'] = sanitize;
- html.sanitizeAttribs = html['sanitizeAttribs'] = sanitizeAttribs;
- html.sanitizeWithPolicy = html['sanitizeWithPolicy'] = sanitizeWithPolicy;
- html.unescapeEntities = html['unescapeEntities'] = unescapeEntities;
- return html;
-})(html4);
-
-var html_sanitize = html['sanitize'];
-
-return {
- html: html
-};
-});
diff --git a/web-ui/app/js/lib/html_whitelister.js b/web-ui/app/js/lib/html_whitelister.js
deleted file mode 100644
index 22841cce..00000000
--- a/web-ui/app/js/lib/html_whitelister.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * Copyright (c) 2014 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/>.
- */
-
-'use strict';
-
-define(['lib/html-sanitizer'], function (htmlSanitizer) {
- var tagAndAttributeWhitelist = {
- 'p': ['style'],
- 'div': ['style'],
- 'a': ['href', 'style'],
- 'span': ['style'],
- 'font': ['face', 'size', 'style'],
- 'img': ['title'],
- 'em': [],
- 'b': [],
- 'i': [],
- 'strong': ['style'],
- 'table': ['style'],
- 'tr': ['style'],
- 'td': ['style'],
- 'th': ['style'],
- 'tbody': ['style'],
- 'thead': ['style'],
- 'dt': ['style'],
- 'dd': ['style'],
- 'dl': ['style'],
- 'h1': ['style'],
- 'h2': ['style'],
- 'h3': ['style'],
- 'h4': ['style'],
- 'h5': ['style'],
- 'h6': ['style'],
- 'br': [],
- 'blockquote': ['style'],
- 'label': ['style'],
- 'form': ['style'],
- 'ol': ['style'],
- 'ul': ['style'],
- 'li': ['style'],
- 'input': ['style', 'type', 'name', 'value']
- };
-
- function filterAllowedAttributes (tagName, attributes) {
- var i, attributesAndValues = [];
-
- for (i = 0; i < attributes.length; i++) {
- if (tagAndAttributeWhitelist[tagName] &&
- _.contains(tagAndAttributeWhitelist[tagName], attributes[i])) {
- attributesAndValues.push(attributes[i]);
- attributesAndValues.push(attributes[i+1]);
- }
- }
-
- return attributesAndValues;
- }
-
- function tagPolicy (tagName, attributes) {
- if (!tagAndAttributeWhitelist[tagName]) {
- return null;
- }
-
- return {
- tagName: tagName,
- attribs: filterAllowedAttributes(tagName, attributes)
- };
- }
-
- return {
- tagPolicy: tagPolicy,
- sanitize: htmlSanitizer.html.sanitizeWithPolicy
- };
-});
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/app/js/page/default.js b/web-ui/app/js/page/default.js
index e33ec723..19b28354 100644
--- a/web-ui/app/js/page/default.js
+++ b/web-ui/app/js/page/default.js
@@ -51,6 +51,7 @@ define(
'mail_view/data/feedback_sender',
'page/version',
'page/unread_count_title',
+ 'helpers/browser'
],
function (
@@ -88,7 +89,8 @@ define(
feedbackBox,
feedbackSender,
version,
- unreadCountTitle) {
+ unreadCountTitle,
+ browser) {
'use strict';
function initialize(path) {
@@ -129,6 +131,8 @@ define(
feedbackSender.attachTo(document);
unreadCountTitle.attachTo(document);
+
+ $.ajaxSetup({headers: {'X-XSRF-TOKEN': browser.getCookie('XSRF-TOKEN')}});
}
return initialize;
diff --git a/web-ui/app/js/page/logout.js b/web-ui/app/js/page/logout.js
index d881f6c2..81b57db2 100644
--- a/web-ui/app/js/page/logout.js
+++ b/web-ui/app/js/page/logout.js
@@ -14,19 +14,28 @@
* 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(['flight/lib/component', 'features', 'views/templates'], function (defineComponent, features, templates) {
+define(['flight/lib/component', 'features', 'views/templates', 'helpers/browser'],
+ function (defineComponent, features, templates, browser) {
'use strict';
return defineComponent(function () {
+ this.defaultAttrs({form: '#logout-form'});
+
this.render = function () {
- var logoutHTML = templates.page.logout({ logout_url: features.getLogoutUrl() });
+ var logoutHTML = templates.page.logout({ logout_url: features.getLogoutUrl(),
+ csrf_token: browser.getCookie('XSRF-TOKEN')});
this.$node.html(logoutHTML);
};
+ this.logout = function(){
+ this.select('form').submit();
+ };
+
this.after('initialize', function () {
if (features.isLogoutEnabled()) {
this.render();
+ this.on(this.$node, 'click', this.logout);
}
});
diff --git a/web-ui/app/js/services/mail_service.js b/web-ui/app/js/services/mail_service.js
index a63d517e..412451cb 100644
--- a/web-ui/app/js/services/mail_service.js
+++ b/web-ui/app/js/services/mail_service.js
@@ -246,7 +246,7 @@ define(
this.trigger(document, events.mails.available, _.merge({tag: this.attr.currentTag, forSearch: this.attr.lastQuery }, this.parseMails(data)));
}.bind(this))
.fail(function () {
- this.trigger(document, events.ui.userAlerts.displayMessage, { message: i18n('Could not fetch messages') });
+ this.trigger(document, events.ui.userAlerts.displayMessage, { message: i18n('Could not fetch messages'), class: 'error' });
}.bind(this));
};
diff --git a/web-ui/app/scss/_mascot.scss b/web-ui/app/scss/_mascot.scss
index 5cfac90d..74279063 100644
--- a/web-ui/app/scss/_mascot.scss
+++ b/web-ui/app/scss/_mascot.scss
@@ -1,5 +1,3 @@
-/* SHEEP */
-
#no-message-selected-pane {
position: absolute;
top: 0;
diff --git a/web-ui/app/scss/styles.scss b/web-ui/app/scss/_styles.scss
index a5a6dca1..63f15f6a 100644
--- a/web-ui/app/scss/styles.scss
+++ b/web-ui/app/scss/_styles.scss
@@ -1,13 +1,6 @@
-
-@import "compass/css3";
-@import "colors";
-@import "mixins";
-@import "alerts";
-@import "read";
-@import "reply";
-@import "compose";
-@import "security";
-
+/*
+ * Miscellaneous styles without apparent grouping
+ */
#logo {
color: $white;
@@ -15,6 +8,7 @@
#logout {
color: $white;
+ cursor: pointer;
}
.search-highlight {
@@ -901,6 +895,3 @@ div.side-nav-bottom {
cursor: progress;
}
}
-
-
-@import "mascot.scss";
diff --git a/web-ui/app/scss/main.scss b/web-ui/app/scss/main.scss
index 7d081ad1..b582a5d5 100644
--- a/web-ui/app/scss/main.scss
+++ b/web-ui/app/scss/main.scss
@@ -2,9 +2,15 @@
@import "reset";
@import "foundation";
@import "colors";
+@import "mixins";
+@import "alerts";
+@import "read";
+@import "reply";
+@import "compose";
+@import "security";
+@import "mascot";
@import "styles";
-
html {
height:100%;
}
diff --git a/web-ui/app/scss/news-cycle.scss b/web-ui/app/scss/news-cycle.scss
deleted file mode 100644
index ecca383c..00000000
--- a/web-ui/app/scss/news-cycle.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-@font-face {
- font-family: 'News Cycle';
- src: url('assets/fonts/NewsCycleRegular.ttf') format('truetype');
- font-weight: normal;
- font-style: normal;
-}
-
-@font-face {
- font-family: 'News Cycle';
- src: url('assets/fonts/NewsCycleBold.ttf') format('truetype');
- font-weight: bold;
- font-style: normal;
-}
diff --git a/web-ui/app/templates/page/logout.hbs b/web-ui/app/templates/page/logout.hbs
index dd931274..3768d24f 100644
--- a/web-ui/app/templates/page/logout.hbs
+++ b/web-ui/app/templates/page/logout.hbs
@@ -1,8 +1,9 @@
<ul id="logout">
- <a title="logout" href={{logout_url}}>
- <li>
+ <form id="logout-form" method="POST" action="{{ logout_url }}">
+ <input type="hidden" name="csrftoken" value="{{ csrf_token }}" />
+ <li>
<div class="fa fa-sign-out"></div>
<i class="shortcut-label"></i> Logout
</li>
- </a>
+ </form>
</ul>
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/browser.spec.js b/web-ui/test/spec/helpers/browser.spec.js
new file mode 100644
index 00000000..5b740da8
--- /dev/null
+++ b/web-ui/test/spec/helpers/browser.spec.js
@@ -0,0 +1,12 @@
+define(['helpers/browser'], function (browser) {
+ 'use strict';
+
+ describe('browser ', function() {
+ it('gets cookie', function() {
+ document.cookie = 'TWISTED_SESSION=ff895ffc45a4ce140bfc5dda6c61d232; i18next=en-us';
+ expect(browser.getCookie('TWISTED_SESSION')).toEqual('ff895ffc45a4ce140bfc5dda6c61d232');
+ expect(browser.getCookie('i18next')).toEqual('en-us');
+ });
+
+ });
+});
diff --git a/web-ui/test/spec/helpers/monitored_ajax_call.spec.js b/web-ui/test/spec/helpers/monitored_ajax_call.spec.js
index 972ca3ae..c0d55198 100644
--- a/web-ui/test/spec/helpers/monitored_ajax_call.spec.js
+++ b/web-ui/test/spec/helpers/monitored_ajax_call.spec.js
@@ -1,6 +1,24 @@
define(['helpers/monitored_ajax'], function (monitoredAjax) {
'use strict';
describe('monitoredAjaxCall', function () {
+
+ describe('default configs', function () {
+
+ it('should always attach the xsrf token in the header', function () {
+ var component = { trigger: function () {}};
+ var d = $.Deferred();
+ spyOn($, 'ajax').and.returnValue(d);
+ document.cookie = 'XSRF-TOKEN=ff895ffc45a4ce140bfc5dda6c61d232; i18next=en-us';
+ var anyUrl = '/';
+
+ monitoredAjax(component, anyUrl, {});
+
+ expect($.ajax.calls.mostRecent().args[1].headers).toEqual({ 'X-XSRF-TOKEN' : 'ff895ffc45a4ce140bfc5dda6c61d232' });
+
+ });
+
+ });
+
describe('when dealing with errors', function () {
_.each(
@@ -19,7 +37,7 @@ define(['helpers/monitored_ajax'], function (monitoredAjax) {
d.reject({ responseJSON: {}}, errorType, '');
expect(component.trigger).toHaveBeenCalledWith(document, Pixelated.events.ui.userAlerts.displayMessage,
- { message: errorMessage });
+ { message: errorMessage, class: 'error' });
});
});
@@ -33,7 +51,7 @@ define(['helpers/monitored_ajax'], function (monitoredAjax) {
d.reject({ responseJSON: { message: 'Server Message'}}, 'error', '');
expect(component.trigger).toHaveBeenCalledWith(document, Pixelated.events.ui.userAlerts.displayMessage,
- { message: 'Server Message' });
+ { message: 'Server Message', class: 'error' });
});
});
@@ -76,4 +94,4 @@ define(['helpers/monitored_ajax'], function (monitoredAjax) {
});
});
-}); \ No newline at end of file
+});
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/spec/mail_view/ui/draft_box.spec.js b/web-ui/test/spec/mail_view/ui/draft_box.spec.js
index f095f5e5..921767ba 100644
--- a/web-ui/test/spec/mail_view/ui/draft_box.spec.js
+++ b/web-ui/test/spec/mail_view/ui/draft_box.spec.js
@@ -1,5 +1,3 @@
-/* global Pixelated */
-
describeComponent('mail_view/ui/draft_box', function () {
'use strict';
diff --git a/web-ui/test/spec/mail_view/ui/draft_save_status.spec.js b/web-ui/test/spec/mail_view/ui/draft_save_status.spec.js
index 0e428066..0db823d9 100644
--- a/web-ui/test/spec/mail_view/ui/draft_save_status.spec.js
+++ b/web-ui/test/spec/mail_view/ui/draft_save_status.spec.js
@@ -1,5 +1,3 @@
-/* global Pixelated */
-
describeComponent('mail_view/ui/draft_save_status', function () {
'use strict';
diff --git a/web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js b/web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js
index a3b3381f..5bca73fe 100644
--- a/web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js
+++ b/web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js
@@ -1,5 +1,3 @@
-/* global Pixelated */
-
describeComponent('mail_view/ui/recipients/recipients',function () {
'use strict';
var recipientsUpdatedEvent;
diff --git a/web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js b/web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js
index 51f18db3..db240379 100644
--- a/web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js
+++ b/web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js
@@ -1,5 +1,3 @@
-/* global Pixelated */
-
define(['mail_view/ui/recipients/recipients_iterator'], function (RecipientsIterator) {
'use strict';
diff --git a/web-ui/test/spec/mail_view/ui/send_button.spec.js b/web-ui/test/spec/mail_view/ui/send_button.spec.js
index 351b4a08..480fe7a8 100644
--- a/web-ui/test/spec/mail_view/ui/send_button.spec.js
+++ b/web-ui/test/spec/mail_view/ui/send_button.spec.js
@@ -1,5 +1,3 @@
-/* global Pixelated */
-
describeComponent('mail_view/ui/send_button', function () {
'use strict';
diff --git a/web-ui/test/spec/page/logout.spec.js b/web-ui/test/spec/page/logout.spec.js
index 7e384cad..a8b882b0 100644
--- a/web-ui/test/spec/page/logout.spec.js
+++ b/web-ui/test/spec/page/logout.spec.js
@@ -8,26 +8,48 @@ describeComponent('page/logout', function () {
features = require('features');
});
- it('should provide logout link if logout is enabled', function () {
+ it('should provide logout form if logout is enabled', function () {
spyOn(features, 'isLogoutEnabled').and.returnValue(true);
this.setupComponent('<nav id="logout"></nav>', {});
- var logout_link = this.component.$node.find('a')[0];
- expect(logout_link).toExist();
- expect(logout_link.href).toMatch('test/logout/url');
+ var logout_form = this.component.$node.find('form')[0];
+ expect(logout_form).toExist();
+ expect(logout_form.action).toMatch('test/logout/url');
+ expect(logout_form.method).toMatch('POST');
});
- it('should not provide logout link if disabled', function() {
+ it('should not provide logout form if logout is disabled', function () {
spyOn(features, 'isLogoutEnabled').and.returnValue(false);
this.setupComponent('<nav id="logout"></nav>', {});
- var logout_link = this.component.$node.find('a')[0];
- expect(logout_link).not.toExist();
+ var logout_form = this.component.$node.find('form')[0];
+ expect(logout_form).not.toExist();
});
- it('should render logout in collapsed nav bar if logout is enabled', function() {
+ it('should provide csrf token if logout is enabled', function () {
+ spyOn(features, 'isLogoutEnabled').and.returnValue(true);
+ document.cookie = 'XSRF-TOKEN=ff895ffc45a4ce140bfc5dda6c61d232; i18next=en-us';
+
+ this.setupComponent('<nav id="logout"></nav>', {});
+
+ var logout_input = this.component.$node.find('input')[0];
+ expect(logout_input).toExist();
+ expect(logout_input.value).toEqual('ff895ffc45a4ce140bfc5dda6c61d232');
+ expect(logout_input.type).toEqual('hidden');
+ });
+
+ it('should not provide csrf token if logout is disabled', function () {
+ spyOn(features, 'isLogoutEnabled').and.returnValue(false);
+
+ this.setupComponent('<nav id="logout"></nav>', {});
+
+ var logout_input = this.component.$node.find('input')[0];
+ expect(logout_input).not.toExist();
+ });
+
+ xit('should render logout in collapsed nav bar if logout is enabled', function() {
spyOn(features, 'isLogoutEnabled').and.returnValue(true);
this.setupComponent('<ul id="logout-shortcuts" class="shortcuts"></ul>', {});
@@ -36,6 +58,21 @@ describeComponent('page/logout', function () {
expect(logout_icon).toExist();
expect(logout_icon.innerHTML).toContain('<div class="fa fa-sign-out"></div>');
});
+
+ it('should submit logout form if logout is enabled', function () {
+ spyOn(features, 'isLogoutEnabled').and.returnValue(true);
+
+ this.setupComponent('<nav id="logout"></nav>', {});
+
+ var logout_form = this.component.$node.find('form')[0];
+ spyOn(logout_form, 'submit');
+
+ this.component.$node.click();
+
+ expect(logout_form.submit).toHaveBeenCalled();
+ });
+
+
});
});
diff --git a/web-ui/test/spec/page/router/url_params.spec.js b/web-ui/test/spec/page/router/url_params.spec.js
index 24cc3797..3c550a43 100644
--- a/web-ui/test/spec/page/router/url_params.spec.js
+++ b/web-ui/test/spec/page/router/url_params.spec.js
@@ -1,5 +1,3 @@
-/* global jasmine */
-
require(['page/router/url_params'], function (urlParams) {
'use strict';
diff --git a/web-ui/test/spec/tags/data/tags.spec.js b/web-ui/test/spec/tags/data/tags.spec.js
index 7c4cd4e0..6760b7ac 100644
--- a/web-ui/test/spec/tags/data/tags.spec.js
+++ b/web-ui/test/spec/tags/data/tags.spec.js
@@ -1,5 +1,3 @@
-/* global Pixelated */
-
describeComponent('tags/data/tags', function () {
'use strict';
diff --git a/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js b/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js
index 5d87795a..bde3b7fa 100644
--- a/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js
+++ b/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js
@@ -1,5 +1,3 @@
-/* global Pixelated */
-
describeComponent('user_alerts/ui/user_alerts', function () {
'use strict';
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 () {