diff --git a/django/contrib/admin/views/decorators.py b/django/contrib/admin/views/decorators.py
index 9dfe651fe691..e68ac1be6f90 100644
--- a/django/contrib/admin/views/decorators.py
+++ b/django/contrib/admin/views/decorators.py
@@ -3,43 +3,21 @@
from django.contrib.auth.models import User
from django.contrib.auth import authenticate, login
from django.shortcuts import render_to_response
+from django.utils.html import escape
from django.utils.translation import gettext_lazy
-import base64, datetime, md5
-import cPickle as pickle
+import base64, datetime
ERROR_MESSAGE = gettext_lazy("Please enter a correct username and password. Note that both fields are case-sensitive.")
LOGIN_FORM_KEY = 'this_is_the_login_form'
def _display_login_form(request, error_message=''):
request.session.set_test_cookie()
- if request.POST and request.POST.has_key('post_data'):
- # User has failed login BUT has previously saved post data.
- post_data = request.POST['post_data']
- elif request.POST:
- # User's session must have expired; save their post data.
- post_data = _encode_post_data(request.POST)
- else:
- post_data = _encode_post_data({})
return render_to_response('admin/login.html', {
'title': _('Log in'),
- 'app_path': request.path,
- 'post_data': post_data,
+ 'app_path': escape(request.path),
'error_message': error_message
}, context_instance=template.RequestContext(request))
-def _encode_post_data(post_data):
- pickled = pickle.dumps(post_data)
- pickled_md5 = md5.new(pickled + settings.SECRET_KEY).hexdigest()
- return base64.encodestring(pickled + pickled_md5)
-
-def _decode_post_data(encoded_data):
- encoded_data = base64.decodestring(encoded_data)
- pickled, tamper_check = encoded_data[:-32], encoded_data[-32:]
- if md5.new(pickled + settings.SECRET_KEY).hexdigest() != tamper_check:
- from django.core.exceptions import SuspiciousOperation
- raise SuspiciousOperation, "User may have tampered with session cookie."
- return pickle.loads(pickled)
-
def staff_member_required(view_func):
"""
Decorator for views that checks that the user is logged in and is a staff
@@ -48,10 +26,6 @@ def staff_member_required(view_func):
def _checklogin(request, *args, **kwargs):
if request.user.is_authenticated() and request.user.is_staff:
# The user is valid. Continue to the admin page.
- if request.POST.has_key('post_data'):
- # User must have re-authenticated through a different window
- # or tab.
- request.POST = _decode_post_data(request.POST['post_data'])
return view_func(request, *args, **kwargs)
assert hasattr(request, 'session'), "The Django admin requires session middleware to be installed. Edit your MIDDLEWARE_CLASSES setting to insert 'django.contrib.sessions.middleware.SessionMiddleware'."
@@ -59,7 +33,7 @@ def _checklogin(request, *args, **kwargs):
# If this isn't already the login page, display it.
if not request.POST.has_key(LOGIN_FORM_KEY):
if request.POST:
- message = _("Please log in again, because your session has expired. Don't worry: Your submission has been saved.")
+ message = _("Please log in again, because your session has expired.")
else:
message = ""
return _display_login_form(request, message)
@@ -92,16 +66,7 @@ def _checklogin(request, *args, **kwargs):
# TODO: set last_login with an event.
user.last_login = datetime.datetime.now()
user.save()
- if request.POST.has_key('post_data'):
- post_data = _decode_post_data(request.POST['post_data'])
- if post_data and not post_data.has_key(LOGIN_FORM_KEY):
- # overwrite request.POST with the saved post_data, and continue
- request.POST = post_data
- request.user = user
- return view_func(request, *args, **kwargs)
- else:
- request.session.delete_test_cookie()
- return http.HttpResponseRedirect(request.path)
+ return http.HttpResponseRedirect(request.path)
else:
return _display_login_form(request, ERROR_MESSAGE)
diff --git a/django/core/management.py b/django/core/management.py
index 091c38b6372d..2968cefdc591 100644
--- a/django/core/management.py
+++ b/django/core/management.py
@@ -1192,9 +1192,7 @@ def inner_run():
print "Development server is running at http://%s:%s/" % (addr, port)
print "Quit the server with %s." % quit_command
try:
- import django
- path = admin_media_dir or django.__path__[0] + '/contrib/admin/media'
- handler = AdminMediaHandler(WSGIHandler(), path)
+ handler = AdminMediaHandler(WSGIHandler(), admin_media_dir)
run(addr, int(port), handler)
except WSGIServerException, e:
# Use helpful error messages instead of ugly tracebacks.
diff --git a/django/core/servers/basehttp.py b/django/core/servers/basehttp.py
index a16b8b675a83..27051d41b1f3 100644
--- a/django/core/servers/basehttp.py
+++ b/django/core/servers/basehttp.py
@@ -11,6 +11,8 @@
from types import ListType, StringType
import os, re, sys, time, urllib
+from django.utils._os import safe_join
+
__version__ = "0.1"
__all__ = ['WSGIServer','WSGIRequestHandler','demo_app']
@@ -599,11 +601,25 @@ def __init__(self, application, media_dir=None):
self.application = application
if not media_dir:
import django
- self.media_dir = django.__path__[0] + '/contrib/admin/media'
+ self.media_dir = \
+ os.path.join(django.__path__[0], 'contrib', 'admin', 'media')
else:
self.media_dir = media_dir
self.media_url = settings.ADMIN_MEDIA_PREFIX
+ def file_path(self, url):
+ """
+ Returns the path to the media file on disk for the given URL.
+
+ The passed URL is assumed to begin with ADMIN_MEDIA_PREFIX. If the
+ resultant file path is outside the media directory, then a ValueError
+ is raised.
+ """
+ # Remove ADMIN_MEDIA_PREFIX.
+ relative_url = url[len(self.media_url):]
+ relative_path = urllib.url2pathname(relative_url)
+ return safe_join(self.media_dir, relative_path)
+
def __call__(self, environ, start_response):
import os.path
@@ -614,19 +630,25 @@ def __call__(self, environ, start_response):
return self.application(environ, start_response)
# Find the admin file and serve it up, if it exists and is readable.
- relative_url = environ['PATH_INFO'][len(self.media_url):]
- file_path = os.path.join(self.media_dir, relative_url)
+ try:
+ file_path = self.file_path(environ['PATH_INFO'])
+ except ValueError: # Resulting file path was not valid.
+ status = '404 NOT FOUND'
+ headers = {'Content-type': 'text/plain'}
+ output = ['Page not found: %s' % environ['PATH_INFO']]
+ start_response(status, headers.items())
+ return output
if not os.path.exists(file_path):
status = '404 NOT FOUND'
headers = {'Content-type': 'text/plain'}
- output = ['Page not found: %s' % file_path]
+ output = ['Page not found: %s' % environ['PATH_INFO']]
else:
try:
fp = open(file_path, 'rb')
except IOError:
status = '401 UNAUTHORIZED'
headers = {'Content-type': 'text/plain'}
- output = ['Permission denied: %s' % file_path]
+ output = ['Permission denied: %s' % environ['PATH_INFO']]
else:
status = '200 OK'
headers = {}
diff --git a/django/utils/_os.py b/django/utils/_os.py
new file mode 100644
index 000000000000..2bb8fc31a76b
--- /dev/null
+++ b/django/utils/_os.py
@@ -0,0 +1,28 @@
+"""
+A back-ported version of the same module from the 1.0.x branch, without the
+unicode support.
+"""
+
+from os.path import join, normcase, abspath, sep
+
+def safe_join(base, *paths):
+ """
+ Joins one or more path components to the base path component intelligently.
+ Returns a normalized, absolute version of the final path.
+
+ The final path must be located inside of the base path component (otherwise
+ a ValueError is raised).
+ """
+ # We need to use normcase to ensure we don't false-negative on case
+ # insensitive operating systems (like Windows).
+ final_path = normcase(abspath(join(base, *paths)))
+ base_path = normcase(abspath(base))
+ base_path_len = len(base_path)
+ # Ensure final_path starts with base_path and that the next character after
+ # the final path is os.sep (or nothing, in which case final_path must be
+ # equal to base_path).
+ if not final_path.startswith(base_path) \
+ or final_path[base_path_len:base_path_len+1] not in ('', sep):
+ raise ValueError('the joined path is located outside of the base path'
+ ' component')
+ return final_path
diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py
index 94df23a8e93c..e576c570e3ca 100644
--- a/django/utils/translation/trans_real.py
+++ b/django/utils/translation/trans_real.py
@@ -1,6 +1,9 @@
"Translation helper functions"
-import os, re, sys
+import locale
+import os
+import re
+import sys
import gettext as gettext_module
from cStringIO import StringIO
from django.utils.functional import lazy
@@ -25,15 +28,25 @@ def currentThread():
# The default translation is based on the settings file.
_default = None
-# This is a cache for accept-header to translation object mappings to prevent
-# the accept parser to run multiple times for one user.
+# This is a cache for normalised accept-header languages to prevent multiple
+# file lookups when checking the same locale on repeated requests.
_accepted = {}
-def to_locale(language):
+# Format of Accept-Language header values. From RFC 2616, section 14.4 and 3.9.
+accept_language_re = re.compile(r'''
+ ([A-Za-z]{1,8}(?:-[A-Za-z]{1,8})*|\*) # "en", "en-au", "x-y-z", "*"
+ (?:;q=(0(?:\.\d{,3})?|1(?:.0{,3})?))? # Optional "q=1.00", "q=0.8"
+ (?:\s*,\s*|$) # Multiple accepts per header.
+ ''', re.VERBOSE)
+
+def to_locale(language, to_lower=False):
"Turns a language name (en-us) into a locale name (en_US)."
p = language.find('-')
if p >= 0:
- return language[:p].lower()+'_'+language[p+1:].upper()
+ if to_lower:
+ return language[:p].lower()+'_'+language[p+1:].lower()
+ else:
+ return language[:p].lower()+'_'+language[p+1:].upper()
else:
return language.lower()
@@ -309,46 +322,40 @@ def get_language_from_request(request):
if lang_code in supported and lang_code is not None and check_for_language(lang_code):
return lang_code
- lang_code = request.COOKIES.get('django_language', None)
- if lang_code in supported and lang_code is not None and check_for_language(lang_code):
+ lang_code = request.COOKIES.get('django_language')
+ if lang_code and lang_code in supported and check_for_language(lang_code):
return lang_code
- accept = request.META.get('HTTP_ACCEPT_LANGUAGE', None)
- if accept is not None:
-
- t = _accepted.get(accept, None)
- if t is not None:
- return t
-
- def _parsed(el):
- p = el.find(';q=')
- if p >= 0:
- lang = el[:p].strip()
- order = int(float(el[p+3:].strip())*100)
- else:
- lang = el
- order = 100
- p = lang.find('-')
- if p >= 0:
- mainlang = lang[:p]
- else:
- mainlang = lang
- return (lang, mainlang, order)
-
- langs = [_parsed(el) for el in accept.split(',')]
- langs.sort(lambda a,b: -1*cmp(a[2], b[2]))
-
- for lang, mainlang, order in langs:
- if lang in supported or mainlang in supported:
- langfile = gettext_module.find('django', globalpath, [to_locale(lang)])
- if langfile:
- # reconstruct the actual language from the language
- # filename, because otherwise we might incorrectly
- # report de_DE if we only have de available, but
- # did find de_DE because of language normalization
- lang = langfile[len(globalpath):].split(os.path.sep)[1]
- _accepted[accept] = lang
- return lang
+ accept = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
+ for lang, unused in parse_accept_lang_header(accept):
+ if lang == '*':
+ break
+
+ # We have a very restricted form for our language files (no encoding
+ # specifier, since they all must be UTF-8 and only one possible
+ # language each time. So we avoid the overhead of gettext.find() and
+ # look up the MO file manually.
+
+ normalized = locale.locale_alias.get(to_locale(lang, True))
+ if not normalized:
+ continue
+
+ # Remove the default encoding from locale_alias
+ normalized = normalized.split('.')[0]
+
+ if normalized in _accepted:
+ # We've seen this locale before and have an MO file for it, so no
+ # need to check again.
+ return _accepted[normalized]
+
+ for lang in (normalized, normalized.split('_')[0]):
+ if lang not in supported:
+ continue
+ langfile = os.path.join(globalpath, lang, 'LC_MESSAGES',
+ 'django.mo')
+ if os.path.exists(langfile):
+ _accepted[normalized] = lang
+ return lang
return settings.LANGUAGE_CODE
@@ -494,3 +501,24 @@ def string_concat(*strings):
return ''.join([str(el) for el in strings])
string_concat = lazy(string_concat, str)
+
+def parse_accept_lang_header(lang_string):
+ """
+ Parses the lang_string, which is the body of an HTTP Accept-Language
+ header, and returns a list of (lang, q-value), ordered by 'q' values.
+
+ Any format errors in lang_string results in an empty list being returned.
+ """
+ result = []
+ pieces = accept_language_re.split(lang_string)
+ if pieces[-1]:
+ return []
+ for i in range(0, len(pieces) - 1, 3):
+ first, lang, priority = pieces[i : i + 3]
+ if first:
+ return []
+ priority = priority and float(priority) or 1.0
+ result.append((lang, priority))
+ result.sort(lambda x, y: -cmp(x[1], y[1]))
+ return result
+
diff --git a/docs/release_notes_0.96.txt b/docs/release_notes_0.96.txt
index ca5f5e045ce5..cc5f2826f5b9 100644
--- a/docs/release_notes_0.96.txt
+++ b/docs/release_notes_0.96.txt
@@ -1,12 +1,12 @@
-=================================
-Django version 0.96 release notes
-=================================
+===================================
+Django version 0.96.1 release notes
+===================================
-Welcome to Django 0.96!
+Welcome to Django 0.96.1!
The primary goal for 0.96 is a cleanup and stabilization of the features
introduced in 0.95. There have been a few small `backwards-incompatible
-changes`_ since 0.95, but the upgrade process should be fairly simple
+changes since 0.95`_, but the upgrade process should be fairly simple
and should not require major changes to existing applications.
However, we're also releasing 0.96 now because we have a set of
@@ -17,9 +17,21 @@ next official release; then you'll be able to upgrade in one step
instead of needing to make incremental changes to keep up with the
development version of Django.
-Backwards-incompatible changes
+Changes since the 0.96 release
==============================
+This release contains fixes for a security vulnerability discovered after the
+initial release of Django 0.96. A bug in the i18n framework could allow an
+attacker to send extremely large strings in the Accept-Language header and
+cause a denial of service by filling available memory.
+
+Because this problems wasn't discovered and fixed until after the 0.96
+release, it's recommended that you use this release rather than the original
+0.96.
+
+Backwards-incompatible changes since 0.95
+=========================================
+
The following changes may require you to update your code when you switch from
0.95 to 0.96:
diff --git a/setup.py b/setup.py
index 6fd6fab81626..31b46606749a 100644
--- a/setup.py
+++ b/setup.py
@@ -32,15 +32,13 @@
for file_info in data_files:
file_info[0] = '/PURELIB/%s' % file_info[0]
-# Dynamically calculate the version based on django.VERSION.
-version = "%d.%d-%s" % (__import__('django').VERSION)
-
setup(
name = "Django",
- version = version,
+ version = "0.96.5",
url = 'http://www.djangoproject.com/',
- author = 'Lawrence Journal-World',
- author_email = 'holovaty@gmail.com',
+ author = 'Django Software Foundation',
+ author_email = 'foundation@djangoproject.com',
+ download_url = 'http://media.djangoproject.com/releases/0.96/Django-0.96.5.tar.gz',
description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.',
packages = packages,
data_files = data_files,
diff --git a/tests/regressiontests/servers/__init__.py b/tests/regressiontests/servers/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/regressiontests/servers/models.py b/tests/regressiontests/servers/models.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/regressiontests/servers/tests.py b/tests/regressiontests/servers/tests.py
new file mode 100644
index 000000000000..7621439032c9
--- /dev/null
+++ b/tests/regressiontests/servers/tests.py
@@ -0,0 +1,67 @@
+"""
+Tests for django.core.servers.
+"""
+
+import os
+
+import django
+from django.test import TestCase
+from django.core.handlers.wsgi import WSGIHandler
+from django.core.servers.basehttp import AdminMediaHandler
+
+
+class AdminMediaHandlerTests(TestCase):
+
+ def setUp(self):
+ self.admin_media_file_path = \
+ os.path.join(django.__path__[0], 'contrib', 'admin', 'media')
+ self.handler = AdminMediaHandler(WSGIHandler())
+
+ def test_media_urls(self):
+ """
+ Tests that URLs that look like absolute file paths after the
+ settings.ADMIN_MEDIA_PREFIX don't turn into absolute file paths.
+ """
+ # Cases that should work on all platforms.
+ data = (
+ ('/media/css/base.css', ('css', 'base.css')),
+ )
+ # Cases that should raise an exception.
+ bad_data = ()
+
+ # Add platform-specific cases.
+ if os.sep == '/':
+ data += (
+ # URL, tuple of relative path parts.
+ ('/media/\\css/base.css', ('\\css', 'base.css')),
+ )
+ bad_data += (
+ '/media//css/base.css',
+ '/media////css/base.css',
+ '/media/../css/base.css',
+ )
+ elif os.sep == '\\':
+ bad_data += (
+ '/media/C:\css/base.css',
+ '/media//\\css/base.css',
+ '/media/\\css/base.css',
+ '/media/\\\\css/base.css'
+ )
+ for url, path_tuple in data:
+ try:
+ output = self.handler.file_path(url)
+ except ValueError:
+ self.fail("Got a ValueError exception, but wasn't expecting"
+ " one. URL was: %s" % url)
+ rel_path = os.path.join(*path_tuple)
+ desired = os.path.normcase(
+ os.path.join(self.admin_media_file_path, rel_path))
+ self.assertEqual(output, desired,
+ "Got: %s, Expected: %s, URL was: %s" % (output, desired, url))
+ for url in bad_data:
+ try:
+ output = self.handler.file_path(url)
+ except ValueError:
+ continue
+ self.fail('URL: %s should have caused a ValueError exception.'
+ % url)