diff --git a/django/__init__.py b/django/__init__.py index a91a7e13c345..e8332faeb2b5 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1 +1 @@ -VERSION = (0, 96, None) +VERSION = (0, 96.5, None) diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index f26911d7f9d5..7cb1e3228520 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -237,7 +237,7 @@ # The User-Agent string to use when checking for URL validity through the # isExistingURL validator. -URL_VALIDATOR_USER_AGENT = "Django/0.96pre (http://www.djangoproject.com)" +URL_VALIDATOR_USER_AGENT = "Django/0.96.5 (http://www.djangoproject.com)" ############## # MIDDLEWARE # diff --git a/django/contrib/admin/templates/admin/login.html b/django/contrib/admin/templates/admin/login.html index 0773132cec19..eab80017fe04 100644 --- a/django/contrib/admin/templates/admin/login.html +++ b/django/contrib/admin/templates/admin/login.html @@ -19,7 +19,6 @@
- {#{% trans 'Have you forgotten your password?' %}#}
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)