Skip to content
Browse files

Initial version.

  • Loading branch information...
0 parents commit 8735f1dc8fb47652b2b815387de38502d37e2adc @carljm committed May 29, 2011
Showing with 632 additions and 0 deletions.
  1. +4 −0 .gitignore
  2. +1 −0 AUTHORS.rst
  3. +8 −0 CHANGES.rst
  4. +28 −0 LICENSE.txt
  5. +5 −0 MANIFEST.in
  6. +61 −0 README.rst
  7. +6 −0 TODO.rst
  8. +1 −0 djangosecure/__init__.py
  9. +24 −0 djangosecure/conf.py
  10. +25 −0 djangosecure/middleware.py
  11. 0 djangosecure/models.py
  12. +219 −0 djangosecure/test_utils.py
  13. +132 −0 djangosecure/tests.py
  14. +35 −0 runtests.py
  15. +46 −0 setup.py
  16. +37 −0 tox.ini
4 .gitignore
@@ -0,0 +1,4 @@
+.tox/
+htmlcov/
+*.egg
+django_secure*.egg-info
1 AUTHORS.rst
@@ -0,0 +1 @@
+Carl Meyer <carl@oddbird.net>
8 CHANGES.rst
@@ -0,0 +1,8 @@
+CHANGES
+=======
+
+0.1.0 (2011.05.29)
+------------------
+
+* Initial release.
+
28 LICENSE.txt
@@ -0,0 +1,28 @@
+Copyright (c) 2011, Carl Meyer
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the name of the author nor the names of other
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
5 MANIFEST.in
@@ -0,0 +1,5 @@
+include AUTHORS.rst
+include CHANGES.rst
+include LICENSE.txt
+include README.rst
+include TODO.rst
61 README.rst
@@ -0,0 +1,61 @@
+=============
+django-secure
+=============
+
+Utilities for running a secure Django site (where all URLs in the site should
+be accessed over an HTTPS connection).
+
+Quickstart
+==========
+
+Dependencies
+------------
+
+Tested with `Django`_ 1.2 and trunk, and `Python`_ 2.5 through 2.7. Almost
+certainly works with older versions of Django, though.
+
+.. _Django: http://www.djangoproject.com/
+.. _Python: http://www.python.org/
+
+Installation
+------------
+
+Install from PyPI with ``pip``::
+
+ pip install django-secure
+
+or get the `in-development version`_::
+
+ pip install django-secure==dev
+
+.. _in-development version: https://github.com/carljm/django-secure/tarball/master#egg=django_secure-dev
+
+Usage
+-----
+
+* Add ``"djangosecure.middleware.SecurityMiddleware"`` to your
+ ``MIDDLEWARE_CLASSES`` setting (where depends on your other middlewares, but
+ near the beginning of the list is probably a good choice).
+
+* Set the ``SECURE_SSL_REDIRECT`` setting to True if all non-SSL requests
+ should be permanently redirected to SSL.
+
+* Set the ``SECURE_STS_SECONDS`` setting to an integer number of seconds, if
+ you want to use `Strict Transport Security`_.
+
+* Set ``SESSION_COOKIE_SECURE`` and ``SESSION_COOKIE_HTTPONLY`` to ``True`` if
+ you are using ``django.contrib.sessions``. These settings are not part of
+ ``django-secure``, but they should be used if running a secure site, and the
+ ``checksecurity`` management command will check their values.
+
+* Run ``python manage.py checksecurity`` to verify that your settings are
+ properly configured for serving a secure SSL site.
+
+.. _Strict Transport Security: http://en.wikipedia.org/wiki/Strict_Transport_Security
+
+Documentation
+-------------
+
+See the `full documentation`_ for more details.
+
+.. _full documentation: http://django-secure.readthedocs.org
6 TODO.rst
@@ -0,0 +1,6 @@
+TODO
+====
+
+* checksecure management command (SESSION_COOKIE_SECURE,
+ SESSION_COOKIE_HTTPONLY, SECURE_STS_SECONDS, SECURE_SSL_REDIRECT,
+ SECURE_FRAME_DENY, SecurityMiddleware)
1 djangosecure/__init__.py
@@ -0,0 +1 @@
+__version__ = "0.1.0a1"
24 djangosecure/conf.py
@@ -0,0 +1,24 @@
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+
+
+
+class Configuration(object):
+ def __init__(self, **kwargs):
+ self.defaults = kwargs
+
+
+ def __getattr__(self, k):
+ try:
+ return getattr(settings, k)
+ except AttributeError:
+ if k in self.defaults:
+ return self.defaults[k]
+ raise ImproperlyConfigured("django-secure requires %s setting." % k)
+
+
+conf = Configuration(
+ SECURE_STS_SECONDS=0,
+ SECURE_FRAME_DENY=True,
+ SECURE_SSL_REDIRECT=False,
+ )
25 djangosecure/middleware.py
@@ -0,0 +1,25 @@
+from django.http import HttpResponsePermanentRedirect
+
+from .conf import conf
+
+
+class SecurityMiddleware(object):
+ def __init__(self):
+ self.sts_seconds = conf.SECURE_STS_SECONDS
+ self.frame_deny = conf.SECURE_FRAME_DENY
+ self.redirect = conf.SECURE_SSL_REDIRECT
+
+
+ def process_request(self, request):
+ if self.redirect and not request.is_secure():
+ return HttpResponsePermanentRedirect(
+ "https://%s%s" % (request.get_host(), request.get_full_path()))
+
+
+ def process_response(self, request, response):
+ if self.frame_deny and not 'x-frame-options' in response:
+ response["x-frame-options"] = "DENY"
+ if self.sts_seconds and not 'strict-transport-security' in response:
+ response["strict-transport-security"] = ("max-age=%s"
+ % self.sts_seconds)
+ return response
0 djangosecure/models.py
No changes.
219 djangosecure/test_utils.py
@@ -0,0 +1,219 @@
+"""
+Testing utilities backported from recent Django versions, for testing with
+older Django versions.
+
+"""
+from __future__ import with_statement
+
+from cStringIO import StringIO
+import urllib
+from urlparse import urlparse
+
+from django.conf import settings, UserSettingsHolder
+from django.core.handlers.wsgi import WSGIRequest
+from django.http import SimpleCookie
+from django.test.client import (
+ encode_multipart, FakePayload, BOUNDARY, MULTIPART_CONTENT, CONTENT_TYPE_RE)
+from django.utils.encoding import smart_str
+from django.utils.functional import wraps
+from django.utils.http import urlencode
+
+
+
+class override_settings(object):
+ """
+ Acts as either a decorator, or a context manager. If it's a decorator it
+ takes a function and returns a wrapped function. If it's a contextmanager
+ it's used with the ``with`` statement. In either event entering/exiting
+ are called before and after, respectively, the function/block is executed.
+
+ """
+ def __init__(self, **kwargs):
+ self.options = kwargs
+ self.wrapped = settings._wrapped
+
+ def __enter__(self):
+ self.enable()
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ self.disable()
+
+ def __call__(self, func):
+ @wraps(func)
+ def inner(*args, **kwargs):
+ with self:
+ return func(*args, **kwargs)
+ return inner
+
+ def enable(self):
+ override = UserSettingsHolder(settings._wrapped)
+ for key, new_value in self.options.items():
+ setattr(override, key, new_value)
+ settings._wrapped = override
+
+ def disable(self):
+ settings._wrapped = self.wrapped
+
+
+
+class RequestFactory(object):
+ """
+ Class that lets you create mock Request objects for use in testing.
+
+ Usage:
+
+ rf = RequestFactory()
+ get_request = rf.get('/hello/')
+ post_request = rf.post('/submit/', {'foo': 'bar'})
+
+ Once you have a request object you can pass it to any view function,
+ just as if that view had been hooked up using a URLconf.
+ """
+ def __init__(self, **defaults):
+ self.defaults = defaults
+ self.cookies = SimpleCookie()
+ self.errors = StringIO()
+
+ def _base_environ(self, **request):
+ """
+ The base environment for a request.
+ """
+ environ = {
+ 'HTTP_COOKIE': self.cookies.output(header='', sep='; '),
+ 'PATH_INFO': '/',
+ 'QUERY_STRING': '',
+ 'REMOTE_ADDR': '127.0.0.1',
+ 'REQUEST_METHOD': 'GET',
+ 'SCRIPT_NAME': '',
+ 'SERVER_NAME': 'testserver',
+ 'SERVER_PORT': '80',
+ 'SERVER_PROTOCOL': 'HTTP/1.1',
+ 'wsgi.version': (1,0),
+ 'wsgi.url_scheme': 'http',
+ 'wsgi.errors': self.errors,
+ 'wsgi.multiprocess': True,
+ 'wsgi.multithread': False,
+ 'wsgi.run_once': False,
+ }
+ environ.update(self.defaults)
+ environ.update(request)
+ return environ
+
+ def request(self, **request):
+ "Construct a generic request object."
+ return WSGIRequest(self._base_environ(**request))
+
+ def _get_path(self, parsed):
+ # If there are parameters, add them
+ if parsed[3]:
+ return urllib.unquote(parsed[2] + ";" + parsed[3])
+ else:
+ return urllib.unquote(parsed[2])
+
+ def get(self, path, data={}, **extra):
+ "Construct a GET request"
+
+ parsed = urlparse(path)
+ r = {
+ 'CONTENT_TYPE': 'text/html; charset=utf-8',
+ 'PATH_INFO': self._get_path(parsed),
+ 'QUERY_STRING': urlencode(data, doseq=True) or parsed[4],
+ 'REQUEST_METHOD': 'GET',
+ 'wsgi.input': FakePayload('')
+ }
+ r.update(extra)
+ return self.request(**r)
+
+ def post(self, path, data={}, content_type=MULTIPART_CONTENT,
+ **extra):
+ "Construct a POST request."
+
+ if content_type is MULTIPART_CONTENT:
+ post_data = encode_multipart(BOUNDARY, data)
+ else:
+ # Encode the content so that the byte representation is correct.
+ match = CONTENT_TYPE_RE.match(content_type)
+ if match:
+ charset = match.group(1)
+ else:
+ charset = settings.DEFAULT_CHARSET
+ post_data = smart_str(data, encoding=charset)
+
+ parsed = urlparse(path)
+ r = {
+ 'CONTENT_LENGTH': len(post_data),
+ 'CONTENT_TYPE': content_type,
+ 'PATH_INFO': self._get_path(parsed),
+ 'QUERY_STRING': parsed[4],
+ 'REQUEST_METHOD': 'POST',
+ 'wsgi.input': FakePayload(post_data),
+ }
+ r.update(extra)
+ return self.request(**r)
+
+ def head(self, path, data={}, **extra):
+ "Construct a HEAD request."
+
+ parsed = urlparse(path)
+ r = {
+ 'CONTENT_TYPE': 'text/html; charset=utf-8',
+ 'PATH_INFO': self._get_path(parsed),
+ 'QUERY_STRING': urlencode(data, doseq=True) or parsed[4],
+ 'REQUEST_METHOD': 'HEAD',
+ 'wsgi.input': FakePayload('')
+ }
+ r.update(extra)
+ return self.request(**r)
+
+ def options(self, path, data={}, **extra):
+ "Constrict an OPTIONS request"
+
+ parsed = urlparse(path)
+ r = {
+ 'PATH_INFO': self._get_path(parsed),
+ 'QUERY_STRING': urlencode(data, doseq=True) or parsed[4],
+ 'REQUEST_METHOD': 'OPTIONS',
+ 'wsgi.input': FakePayload('')
+ }
+ r.update(extra)
+ return self.request(**r)
+
+ def put(self, path, data={}, content_type=MULTIPART_CONTENT,
+ **extra):
+ "Construct a PUT request."
+
+ if content_type is MULTIPART_CONTENT:
+ post_data = encode_multipart(BOUNDARY, data)
+ else:
+ post_data = data
+
+ # Make `data` into a querystring only if it's not already a string. If
+ # it is a string, we'll assume that the caller has already encoded it.
+ query_string = None
+ if not isinstance(data, basestring):
+ query_string = urlencode(data, doseq=True)
+
+ parsed = urlparse(path)
+ r = {
+ 'CONTENT_LENGTH': len(post_data),
+ 'CONTENT_TYPE': content_type,
+ 'PATH_INFO': self._get_path(parsed),
+ 'QUERY_STRING': query_string or parsed[4],
+ 'REQUEST_METHOD': 'PUT',
+ 'wsgi.input': FakePayload(post_data),
+ }
+ r.update(extra)
+ return self.request(**r)
+
+ def delete(self, path, data={}, **extra):
+ "Construct a DELETE request."
+
+ parsed = urlparse(path)
+ r = {
+ 'PATH_INFO': self._get_path(parsed),
+ 'QUERY_STRING': urlencode(data, doseq=True) or parsed[4],
+ 'REQUEST_METHOD': 'DELETE',
+ 'wsgi.input': FakePayload('')
+ }
+ r.update(extra)
+ return self.request(**r)
132 djangosecure/tests.py
@@ -0,0 +1,132 @@
+from django.http import HttpResponse
+from django.test import TestCase
+
+from .test_utils import override_settings, RequestFactory
+
+
+
+class SecurityMiddlewareTest(TestCase):
+ @property
+ def middleware(self):
+ from djangosecure.middleware import SecurityMiddleware
+ return SecurityMiddleware()
+
+
+ def response(self, *args, **kwargs):
+ headers = kwargs.pop("headers", {})
+ response = HttpResponse(*args, **kwargs)
+ for k, v in headers.iteritems():
+ response[k] = v
+ return response
+
+
+ def process_response(self, *args, **kwargs):
+ return self.middleware.process_response(
+ "request not used", self.response(*args, **kwargs))
+
+
+ request = RequestFactory()
+
+
+ def process_request(self, method, *args, **kwargs):
+ if kwargs.pop("secure", False):
+ kwargs["wsgi.url_scheme"] = "https"
+ req = getattr(self.request, method.lower())(*args, **kwargs)
+ return self.middleware.process_request(req)
+
+
+ @override_settings(SECURE_FRAME_DENY=True)
+ def test_frame_deny_on(self):
+ """
+ With SECURE_FRAME_DENY True, the middleware adds "x-frame-options:
+ DENY" to the response.
+
+ """
+ self.assertEqual(self.process_response()["x-frame-options"], "DENY")
+
+
+ @override_settings(SECURE_FRAME_DENY=True)
+ def test_frame_deny_already_present(self):
+ """
+ The middleware will not override an "x-frame-options" header already
+ present in the response.
+
+ """
+ response = self.process_response(headers={"x-frame-options": "ALLOW"})
+ self.assertEqual(response["x-frame-options"], "ALLOW")
+
+
+ @override_settings(SECURE_FRAME_DENY=False)
+ def test_frame_deny_off(self):
+ """
+ With SECURE_FRAME_DENY False, the middleware does not add an
+ "x-frame-options" header to the response.
+
+ """
+ self.assertFalse("x-frame-options" in self.process_response())
+
+
+ @override_settings(SECURE_STS_SECONDS=3600)
+ def test_sts_on(self):
+ """
+ With SECURE_STS_SECONDS=3600, the middleware adds
+ "strict-transport-security: max-age=3600" to the response.
+
+ """
+ self.assertEqual(
+ self.process_response()["strict-transport-security"],
+ "max-age=3600")
+
+
+ @override_settings(SECURE_STS_SECONDS=3600)
+ def test_sts_already_present(self):
+ """
+ The middleware will not override a "strict-transport-security" header
+ already present in the response.
+
+ """
+ response = self.process_response(
+ headers={"strict-transport-security": "max-age=7200"})
+ self.assertEqual(response["strict-transport-security"], "max-age=7200")
+
+
+ @override_settings(SECURE_STS_SECONDS=0)
+ def test_sts_off(self):
+ """
+ With SECURE_STS_SECONDS of 0, the middleware does not add an
+ "strict-transport-security" header to the response.
+
+ """
+ self.assertFalse("strict-transport-security" in self.process_response())
+
+
+ @override_settings(SECURE_SSL_REDIRECT=True)
+ def test_ssl_redirect_on(self):
+ """
+ With SECURE_SSL_REDIRECT True, the middleware redirects any non-secure
+ requests to the https:// version of the same URL.
+
+ """
+ ret = self.process_request("get", "/some/url")
+ self.assertEqual(ret.status_code, 301)
+ self.assertEqual(ret["Location"], "https://testserver/some/url")
+
+
+ @override_settings(SECURE_SSL_REDIRECT=True)
+ def test_no_redirect_ssl(self):
+ """
+ The middleware does not redirect secure requests.
+
+ """
+ ret = self.process_request("get", "/some/url", secure=True)
+ self.assertEqual(ret, None)
+
+
+ @override_settings(SECURE_SSL_REDIRECT=False)
+ def test_ssl_redirect_off(self):
+ """
+ With SECURE_SSL_REDIRECT False, the middleware does no redirect.
+
+ """
+ ret = self.process_request("get", "/some/url")
+ self.assertEqual(ret, None)
35 runtests.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python
+
+import os, sys
+
+from django.conf import settings
+
+
+if not settings.configured:
+ settings.configure(
+ INSTALLED_APPS=["djangosecure"],
+ DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3"}})
+
+
+def runtests(*test_args):
+ if not test_args:
+ test_args = ["djangosecure"]
+
+ parent = os.path.dirname(os.path.abspath(__file__))
+ sys.path.insert(0, parent)
+
+ try:
+ from django.test.simple import DjangoTestSuiteRunner
+ def run_tests(test_args, verbosity, interactive):
+ runner = DjangoTestSuiteRunner(
+ verbosity=verbosity, interactive=interactive, failfast=False)
+ return runner.run_tests(test_args)
+ except ImportError:
+ # for Django versions that don't have DjangoTestSuiteRunner
+ from django.test.simple import run_tests
+ failures = run_tests(test_args, verbosity=1, interactive=True)
+ sys.exit(failures)
+
+
+if __name__ == '__main__':
+ runtests()
46 setup.py
@@ -0,0 +1,46 @@
+from os.path import join, dirname
+
+from setuptools import setup, find_packages
+
+here = dirname(__file__)
+
+long_description = (open(join(here, 'README.rst')).read() + "\n\n" +
+ open(join(here, 'CHANGES.rst')).read() + "\n\n" +
+ open(join(here, 'TODO.rst')).read())
+
+def get_version():
+ fh = open(join(here, "djangosecure", "__init__.py"))
+ try:
+ for line in fh.readlines():
+ if line.startswith("__version__ ="):
+ return line.split("=")[1].strip()
+ finally:
+ fh.close()
+
+setup(
+ name='django-secure',
+ version=get_version(),
+ description='Utilities for an SSL-only Django site',
+ long_description=long_description,
+ author='Carl Meyer',
+ author_email='carl@oddbird.net',
+ url='https://github.com/carljm/django-secure/',
+ packages=find_packages(),
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Web Environment',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: BSD License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.4',
+ 'Programming Language :: Python :: 2.5',
+ 'Programming Language :: Python :: 2.6',
+ 'Programming Language :: Python :: 2.7',
+ 'Framework :: Django',
+ ],
+ zip_safe=False,
+ tests_require=["Django>=1.2"],
+ test_suite='runtests.runtests'
+)
37 tox.ini
@@ -0,0 +1,37 @@
+[tox]
+envlist=py25-1.2,py25,py25-trunk,py26-1.2,py26,py26-trunk,py27-1.2,py27,py27-trunk
+
+[testenv]
+deps=
+ django==1.3
+commands=python setup.py test
+
+[testenv:py25-1.2]
+basepython=python2.5
+deps=
+ django==1.2.5
+
+[testenv:py25-trunk]
+basepython=python2.5
+deps=
+ svn+http://code.djangoproject.com/svn/django/trunk#egg=django
+
+[testenv:py26-1.2]
+basepython=python2.6
+deps=
+ django==1.2.5
+
+[testenv:py26-trunk]
+basepython=python2.6
+deps=
+ svn+http://code.djangoproject.com/svn/django/trunk#egg=django
+
+[testenv:py27-1.2]
+basepython=python2.7
+deps=
+ django==1.2.5
+
+[testenv:py27-trunk]
+basepython=python2.7
+deps=
+ svn+http://code.djangoproject.com/svn/django/trunk#egg=django

0 comments on commit 8735f1d

Please sign in to comment.
Something went wrong with that request. Please try again.