Repository: django/channels

Issue:
use of the CSRF cookie for authentification
Otherwise an user is already logged in, in all new connections even from other sites witch makes it attacking quiet easy.


Discussion:
The designed mechanism for WebSockets to check origins is the `Origin` header that's passed to you during the connection phase; in Channels that's something you can check in a `websocket.connect` handler and accept/deny based on origin there.

I'll leave this open as something to potentially implement in future though as it would probably be easier for those with dynamic domains or similar.
Maybe it would help if channels checks by default if the origin is in the setting.allowHosts list. This would help to have better security by default or am I missing something? If someone would like to allow all origins a decorator could help here.
That would probably be a sensible default behaviour, yes.

Resolving patch:
diff --git a/channels/security/__init__.py b/channels/security/__init__.py
new file mode 100644
diff --git a/channels/security/websockets.py b/channels/security/websockets.py
new file mode 100644
--- /dev/null
+++ b/channels/security/websockets.py
@@ -0,0 +1,90 @@
+from functools import update_wrapper
+
+from django.conf import settings
+from django.http.request import validate_host
+
+from ..exceptions import DenyConnection
+
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+class BaseOriginValidator(object):
+    """
+    Base class-based decorator for origin validation of WebSocket connect
+    messages.
+
+    This base class handles parsing of the origin header. When the origin header
+    is missing, empty or contains non-ascii characters, it raises a
+    DenyConnection exception to reject the connection.
+
+    Subclasses must overwrite the method validate_origin(self, message, origin)
+    to return True when a message should be accepted, False otherwise.
+    """
+
+    def __init__(self, func):
+        update_wrapper(self, func)
+        self.func = func
+
+    def __call__(self, message, *args, **kwargs):
+        origin = self.get_origin(message)
+        if not self.validate_origin(message, origin):
+            raise DenyConnection
+        return self.func(message, *args, **kwargs)
+
+    def get_header(self, message, name):
+        headers = message.content['headers']
+        for header in headers:
+            try:
+                if header[0] == name:
+                    return header[1:]
+            except IndexError:
+                continue
+        raise KeyError('No header named "{}"'.format(name))
+
+    def get_origin(self, message):
+        """
+        Returns the origin of a WebSocket connect message.
+
+        Raises DenyConnection for messages with missing or non-ascii Origin
+        header.
+        """
+        try:
+            header = self.get_header(message, b'origin')[0]
+        except (IndexError, KeyError):
+            raise DenyConnection
+        try:
+            origin = header.decode('ascii')
+        except UnicodeDecodeError:
+            raise DenyConnection
+        return origin
+
+    def validate_origin(self, message, origin):
+        """
+        Validates the origin of a WebSocket connect message.
+
+        Must be overwritten by subclasses.
+        """
+        raise NotImplemented('You must overwrite this method.')
+
+
+class AllowedHostsOnlyOriginValidator(BaseOriginValidator):
+    """
+    Class-based decorator for websocket consumers that checks that
+    the origin is allowed according to the ALLOWED_HOSTS settings.
+    """
+
+    def validate_origin(self, message, origin):
+        allowed_hosts = settings.ALLOWED_HOSTS
+        if settings.DEBUG and not allowed_hosts:
+            allowed_hosts = ['localhost', '127.0.0.1', '[::1]']
+
+        origin_hostname = urlparse(origin).hostname
+        valid = (origin_hostname and
+                 validate_host(origin_hostname, allowed_hosts))
+        return valid
+
+
+allowed_hosts_only = AllowedHostsOnlyOriginValidator


Test patch:
diff --git a/tests/test_security.py b/tests/test_security.py
new file mode 100644
--- /dev/null
+++ b/tests/test_security.py
@@ -0,0 +1,44 @@
+from __future__ import unicode_literals
+
+from django.test import override_settings
+from channels.exceptions import DenyConnection
+from channels.security.websockets import allowed_hosts_only
+from channels.message import Message
+from channels.test import ChannelTestCase
+
+
+@allowed_hosts_only
+def connect(message):
+    return True
+
+
+class OriginValidationTestCase(ChannelTestCase):
+
+    @override_settings(ALLOWED_HOSTS=['example.com'])
+    def test_valid_origin(self):
+        content = {
+            'headers': [[b'origin', b'http://example.com']]
+        }
+        message = Message(content, 'websocket.connect', None)
+        self.assertTrue(connect(message))
+
+    @override_settings(ALLOWED_HOSTS=['example.com'])
+    def test_invalid_origin(self):
+        content = {
+            'headers': [[b'origin', b'http://example.org']]
+        }
+        message = Message(content, 'websocket.connect', None)
+        self.assertRaises(DenyConnection, connect, message)
+
+    def test_invalid_origin_header(self):
+        invalid_headers = [
+            [],  # origin header missing
+            [b'origin', b''],  # origin header empty
+            [b'origin', b'\xc3\xa4']  # non-ascii
+        ]
+        for headers in invalid_headers:
+            content = {
+                'headers': [headers]
+            }
+            message = Message(content, 'websocket.connect', None)
+            self.assertRaises(DenyConnection, connect, message)