Browse Source

Fixed #3304 -- Added support for HTTPOnly cookies. Thanks to arvin for the suggestion, and rodolfo for the draft patch.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@14707 bcc190cf-cafb-0310-a4f2-bffc1f526a37
Russell Keith-Magee 14 years ago
parent
commit
78be884ea7

+ 1 - 0
django/conf/global_settings.py

@@ -421,6 +421,7 @@ SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 * 2               # Age of cookie, in seco
 SESSION_COOKIE_DOMAIN = None                            # A string like ".lawrence.com", or None for standard domain cookie.
 SESSION_COOKIE_SECURE = False                           # Whether the session cookie should be secure (https:// only).
 SESSION_COOKIE_PATH = '/'                               # The path of the session cookie.
+SESSION_COOKIE_HTTPONLY = False                         # Whether to use the non-RFC standard httpOnly flag (IE, FF3+, others)
 SESSION_SAVE_EVERY_REQUEST = False                      # Whether to save the session data on every request.
 SESSION_EXPIRE_AT_BROWSER_CLOSE = False                 # Whether a user's session cookie expires when the Web browser is closed.
 SESSION_ENGINE = 'django.contrib.sessions.backends.db'  # The module to store session data

+ 2 - 1
django/contrib/sessions/middleware.py

@@ -38,5 +38,6 @@ class SessionMiddleware(object):
                         request.session.session_key, max_age=max_age,
                         expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
                         path=settings.SESSION_COOKIE_PATH,
-                        secure=settings.SESSION_COOKIE_SECURE or None)
+                        secure=settings.SESSION_COOKIE_SECURE or None,
+                        httponly=settings.SESSION_COOKIE_HTTPONLY or None)
         return response

+ 43 - 1
django/contrib/sessions/tests.py

@@ -11,8 +11,10 @@ from django.contrib.sessions.backends.cached_db import SessionStore as CacheDBSe
 from django.contrib.sessions.backends.file import SessionStore as FileSession
 from django.contrib.sessions.backends.base import SessionBase
 from django.contrib.sessions.models import Session
+from django.contrib.sessions.middleware import SessionMiddleware
 from django.core.exceptions import ImproperlyConfigured
-from django.test import TestCase
+from django.http import HttpResponse
+from django.test import TestCase, RequestFactory
 from django.utils import unittest
 from django.utils.hashcompat import md5_constructor
 
@@ -320,3 +322,43 @@ class FileSessionTests(SessionTestsMixin, unittest.TestCase):
 class CacheSessionTests(SessionTestsMixin, unittest.TestCase):
 
     backend = CacheSession
+
+
+class SessionMiddlewareTests(unittest.TestCase):
+    def setUp(self):
+        self.old_SESSION_COOKIE_SECURE = settings.SESSION_COOKIE_SECURE
+        self.old_SESSION_COOKIE_HTTPONLY = settings.SESSION_COOKIE_HTTPONLY
+
+    def tearDown(self):
+        settings.SESSION_COOKIE_SECURE = self.old_SESSION_COOKIE_SECURE
+        settings.SESSION_COOKIE_HTTPONLY = self.old_SESSION_COOKIE_HTTPONLY
+
+    def test_secure_session_cookie(self):
+        settings.SESSION_COOKIE_SECURE = True
+
+        request = RequestFactory().get('/')
+        response = HttpResponse('Session test')
+        middleware = SessionMiddleware()
+
+        # Simulate a request the modifies the session
+        middleware.process_request(request)
+        request.session['hello'] = 'world'
+
+        # Handle the response through the middleware
+        response = middleware.process_response(request, response)
+        self.assertTrue(response.cookies[settings.SESSION_COOKIE_NAME]['secure'])
+
+    def test_httponly_session_cookie(self):
+        settings.SESSION_COOKIE_HTTPONLY = True
+
+        request = RequestFactory().get('/')
+        response = HttpResponse('Session test')
+        middleware = SessionMiddleware()
+
+        # Simulate a request the modifies the session
+        middleware.process_request(request)
+        request.session['hello'] = 'world'
+
+        # Handle the response through the middleware
+        response = middleware.process_response(request, response)
+        self.assertTrue(response.cookies[settings.SESSION_COOKIE_NAME]['httponly'])

+ 38 - 4
django/http/__init__.py

@@ -2,7 +2,6 @@ import datetime
 import os
 import re
 import time
-from Cookie import BaseCookie, SimpleCookie, CookieError
 from pprint import pformat
 from urllib import urlencode
 from urlparse import urljoin
@@ -22,6 +21,39 @@ except ImportError:
         # PendingDeprecationWarning
         from cgi import parse_qsl
 
+# httponly support exists in Python 2.6's Cookie library,
+# but not in Python 2.4 or 2.5.
+import Cookie
+if Cookie.Morsel._reserved.has_key('httponly'):
+    SimpleCookie = Cookie.SimpleCookie
+else:
+    class Morsel(Cookie.Morsel):
+        def __setitem__(self, K, V):
+            K = K.lower()
+            if K == "httponly":
+                if V:
+                    # The superclass rejects httponly as a key,
+                    # so we jump to the grandparent.
+                    super(Cookie.Morsel, self).__setitem__(K, V)
+            else:
+                super(Morsel, self).__setitem__(K, V)
+
+        def OutputString(self, attrs=None):
+            output = super(Morsel, self).OutputString(attrs)
+            if "httponly" in self:
+                output += "; httponly"
+            return output
+
+    class SimpleCookie(Cookie.SimpleCookie):
+        def __set(self, key, real_value, coded_value):
+            M = self.get(key, Morsel())
+            M.set(key, real_value, coded_value)
+            dict.__setitem__(self, key, M)
+
+        def __setitem__(self, key, value):
+            rval, cval = self.value_encode(value)
+            self.__set(key, rval, cval)
+
 from django.utils.datastructures import MultiValueDict, ImmutableList
 from django.utils.encoding import smart_str, iri_to_uri, force_unicode
 from django.utils.http import cookie_date
@@ -369,11 +401,11 @@ class CompatCookie(SimpleCookie):
 def parse_cookie(cookie):
     if cookie == '':
         return {}
-    if not isinstance(cookie, BaseCookie):
+    if not isinstance(cookie, Cookie.BaseCookie):
         try:
             c = CompatCookie()
             c.load(cookie)
-        except CookieError:
+        except Cookie.CookieError:
             # Invalid cookie
             return {}
     else:
@@ -462,7 +494,7 @@ class HttpResponse(object):
         return self._headers.get(header.lower(), (None, alternate))[1]
 
     def set_cookie(self, key, value='', max_age=None, expires=None, path='/',
-                   domain=None, secure=False):
+                   domain=None, secure=False, httponly=False):
         """
         Sets a cookie.
 
@@ -495,6 +527,8 @@ class HttpResponse(object):
             self.cookies[key]['domain'] = domain
         if secure:
             self.cookies[key]['secure'] = True
+        if httponly:
+            self.cookies[key]['httponly'] = True
 
     def delete_cookie(self, key, path='/', domain=None):
         self.set_cookie(key, max_age=0, path=path, domain=domain,

+ 17 - 7
docs/ref/request-response.txt

@@ -566,7 +566,13 @@ Methods
     Returns ``True`` or ``False`` based on a case-insensitive check for a
     header with the given name.
 
-.. method:: HttpResponse.set_cookie(key, value='', max_age=None, expires=None, path='/', domain=None, secure=None)
+.. method:: HttpResponse.set_cookie(key, value='', max_age=None, expires=None, path='/', domain=None, secure=None, httponly=False)
+
+    .. versionchanged:: 1.3
+
+    The possibility of specifying a ``datetime.datetime`` object in
+    ``expires``, and the auto-calculation of ``max_age`` in such case
+    was added. The ``httponly`` argument was also added.
 
     Sets a cookie. The parameters are the same as in the `cookie Morsel`_
     object in the Python standard library.
@@ -583,14 +589,18 @@ Methods
           the domains www.lawrence.com, blogs.lawrence.com and
           calendars.lawrence.com. Otherwise, a cookie will only be readable by
           the domain that set it.
+        * Use ``http_only=True`` if you want to prevent client-side
+          JavaScript from having access to the cookie.
 
-    .. _`cookie Morsel`: http://docs.python.org/library/cookie.html#Cookie.Morsel
+          HTTPOnly_ is a flag included in a Set-Cookie HTTP response
+          header. It is not part of the RFC2109 standard for cookies,
+          and it isn't honored consistently by all browsers. However,
+          when it is honored, it can be a useful way to mitigate the
+          risk of client side script accessing the protected cookie
+          data.
 
-    .. versionchanged:: 1.3
-
-    Both the possibility of specifying a ``datetime.datetime`` object in
-    ``expires`` and the auto-calculation of ``max_age`` in such case were added
-    in Django 1.3.
+    .. _`cookie Morsel`: http://docs.python.org/library/cookie.html#Cookie.Morsel
+    .. _HTTPOnly: http://www.owasp.org/index.php/HTTPOnly
 
 .. method:: HttpResponse.delete_cookie(key, path='/', domain=None)
 

+ 19 - 0
docs/ref/settings.txt

@@ -1392,6 +1392,25 @@ The domain to use for session cookies. Set this to a string such as
 ``".lawrence.com"`` for cross-domain cookies, or use ``None`` for a standard
 domain cookie. See the :doc:`/topics/http/sessions`.
 
+.. setting:: SESSION_COOKIE_HTTPONLY
+
+SESSION_COOKIE_HTTPONLY
+-----------------------
+
+Default: ``False``
+
+Whether to use HTTPOnly flag on the session cookie. If this is set to
+``True``, client-side JavaScript will not to be able to access the
+session cookie.
+
+HTTPOnly_ is a flag included in a Set-Cookie HTTP response header. It
+is not part of the RFC2109 standard for cookies, and it isn't honored
+consistently by all browsers. However, when it is honored, it can be a
+useful way to mitigate the risk of client side script accessing the
+protected cookie data.
+
+.. _HTTPOnly: http://www.owasp.org/index.php/HTTPOnly
+
 .. setting:: SESSION_COOKIE_NAME
 
 SESSION_COOKIE_NAME

+ 4 - 0
docs/releases/1.3.txt

@@ -161,6 +161,10 @@ requests. These include:
 
     * Support for lookups spanning relations in admin's ``list_filter``.
 
+    * Support for _HTTPOnly cookies.
+
+.. _HTTPOnly: http://www.owasp.org/index.php/HTTPOnly
+
 .. _backwards-incompatible-changes-1.3:
 
 Backwards-incompatible changes in 1.3

+ 17 - 0
docs/topics/http/sessions.txt

@@ -457,6 +457,23 @@ The domain to use for session cookies. Set this to a string such as
 ``".lawrence.com"`` (note the leading dot!) for cross-domain cookies, or use
 ``None`` for a standard domain cookie.
 
+SESSION_COOKIE_HTTPONLY
+-----------------------
+
+Default: ``False``
+
+Whether to use HTTPOnly flag on the session cookie. If this is set to
+``True``, client-side JavaScript will not to be able to access the
+session cookie.
+
+HTTPOnly_ is a flag included in a Set-Cookie HTTP response header. It
+is not part of the RFC2109 standard for cookies, and it isn't honored
+consistently by all browsers. However, when it is honored, it can be a
+useful way to mitigate the risk of client side script accessing the
+protected cookie data.
+
+.. _HTTPOnly: http://www.owasp.org/index.php/HTTPOnly
+
 SESSION_COOKIE_NAME
 -------------------
 

+ 9 - 0
tests/regressiontests/requests/tests.py

@@ -89,6 +89,15 @@ class RequestsTests(unittest.TestCase):
         self.assertEqual(max_age_cookie['max-age'], 10)
         self.assertEqual(max_age_cookie['expires'], cookie_date(time.time()+10))
 
+    def test_httponly_cookie(self):
+        response = HttpResponse()
+        response.set_cookie('example', httponly=True)
+        example_cookie = response.cookies['example']
+        # A compat cookie may be in use -- check that it has worked
+        # both as an output string, and using the cookie attributes
+        self.assertTrue('; httponly' in str(example_cookie))
+        self.assertTrue(example_cookie['httponly'])
+
     def test_limited_stream(self):
         # Read all of a limited stream
         stream = LimitedStream(StringIO('test'), 2)