From 78be884ea788835ad98ad433862a82cf192c3d4f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 26 Nov 2010 13:30:50 +0000 Subject: [PATCH] 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 --- django/conf/global_settings.py | 1 + django/contrib/sessions/middleware.py | 3 +- django/contrib/sessions/tests.py | 44 ++++++++++++++++++++++++- django/http/__init__.py | 42 ++++++++++++++++++++--- docs/ref/request-response.txt | 24 ++++++++++---- docs/ref/settings.txt | 19 +++++++++++ docs/releases/1.3.txt | 4 +++ docs/topics/http/sessions.txt | 17 ++++++++++ tests/regressiontests/requests/tests.py | 9 +++++ 9 files changed, 150 insertions(+), 13 deletions(-) diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 599200ad0b..f23c55d20d 100644 --- a/django/conf/global_settings.py +++ b/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 diff --git a/django/contrib/sessions/middleware.py b/django/contrib/sessions/middleware.py index 57fcb9015a..68cb77f7e1 100644 --- a/django/contrib/sessions/middleware.py +++ b/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 diff --git a/django/contrib/sessions/tests.py b/django/contrib/sessions/tests.py index 9000714dc4..e8aad0f05f 100644 --- a/django/contrib/sessions/tests.py +++ b/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']) diff --git a/django/http/__init__.py b/django/http/__init__.py index b40558544e..42027f0beb 100644 --- a/django/http/__init__.py +++ b/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, diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 7f2284f9f5..cc89229725 100644 --- a/docs/ref/request-response.txt +++ b/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. + + 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. .. _`cookie Morsel`: http://docs.python.org/library/cookie.html#Cookie.Morsel - - .. 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. + .. _HTTPOnly: http://www.owasp.org/index.php/HTTPOnly .. method:: HttpResponse.delete_cookie(key, path='/', domain=None) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index b551a27093..3577ab0ceb 100644 --- a/docs/ref/settings.txt +++ b/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 diff --git a/docs/releases/1.3.txt b/docs/releases/1.3.txt index eea75470f6..1dc6870496 100644 --- a/docs/releases/1.3.txt +++ b/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 diff --git a/docs/topics/http/sessions.txt b/docs/topics/http/sessions.txt index 8a0f0d4b72..dd48c72a23 100644 --- a/docs/topics/http/sessions.txt +++ b/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 ------------------- diff --git a/tests/regressiontests/requests/tests.py b/tests/regressiontests/requests/tests.py index 0bba733795..b1d80fe30e 100644 --- a/tests/regressiontests/requests/tests.py +++ b/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)