From bbb2595f89c43df03e33283e1bbbbfc38f0697c8 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Thu, 29 Mar 2012 06:18:26 +0000 Subject: [PATCH 001/367] Create 1.4 release branch. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17814 bcc190cf-cafb-0310-a4f2-bffc1f526a37 From a815fd1652f493d1f51b8c585f1919c46a19afa3 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 29 Mar 2012 15:05:31 +0000 Subject: [PATCH 002/367] [1.4.X] Bump the version in a docs example. Backport of r17801 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17815 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/intro/install.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro/install.txt b/docs/intro/install.txt index e29266a0bde7..1efd182260d7 100644 --- a/docs/intro/install.txt +++ b/docs/intro/install.txt @@ -86,7 +86,7 @@ Then at the Python prompt, try to import Django:: >>> import django >>> print django.get_version() - 1.3 + 1.4 That's it! From ec2119e1940667a56a7b5c62020739def3b5a68e Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 29 Mar 2012 15:07:09 +0000 Subject: [PATCH 003/367] [1.4.X] Fixed #17963 -- Fixed internal links in the 1.4 release notes. Backport of r17802 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17816 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/releases/1.4.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 8d0cd753c543..51766dc2f5ca 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -34,7 +34,7 @@ out the :ref:`timezone migration guide ` and the Other notable new features in Django 1.4 include: * A number of ORM improvements, including `SELECT FOR UPDATE support`_, - the ability to `bulk insert `_ + the ability to `bulk insert <#model-objects-bulk-create-in-the-orm>`_ large datasets for improved performance, and `QuerySet.prefetch_related`_, a method to batch-load related objects in areas where :meth:`~django.db.models.QuerySet.select_related` does't @@ -51,7 +51,7 @@ Other notable new features in Django 1.4 include: * `Support for in-browser testing frameworks`_ (like Selenium_). -* ... and a whole lot more; `see below `_! +* ... and a whole lot more; `see below <#what-s-new-in-django-1-4>`_! Wherever possible we try to introduce new features in a backwards-compatible manner per :doc:`our API stability policy ` policy. From 37c0e10e8c2a8630c372d15b36cc4f07b7a2830b Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 29 Mar 2012 15:08:38 +0000 Subject: [PATCH 004/367] [1.4.X] Fix lintian error in manpages. Backport of r17808 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17817 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/man/django-admin.1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/man/django-admin.1 b/docs/man/django-admin.1 index 38d9c288e728..1f693b897513 100644 --- a/docs/man/django-admin.1 +++ b/docs/man/django-admin.1 @@ -194,7 +194,7 @@ The domain of the message files (default: "django") when using makemessages. .TP .I \-e, \-\-extension=EXTENSION The file extension(s) to examine (separate multiple -extensions with commas, or use -e multiple times) (makemessages command). +extensions with commas, or use \-e multiple times) (makemessages command). .TP .I \-s, \-\-symlinks Follows symlinks to directories when examining source code and templates for From 277661c2af8e183e08916c9ca85581a2611d75a4 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 29 Mar 2012 15:09:52 +0000 Subject: [PATCH 005/367] [1.4.X] Fixed #17733 -- Discouraged setting TIME_ZONE to None when USE_TZ is True. Thanks berdario for the report. Backport of r17809 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17818 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/conf/project_template/project_name/settings.py | 5 +---- docs/ref/settings.txt | 6 ++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/django/conf/project_template/project_name/settings.py b/django/conf/project_template/project_name/settings.py index a5a25b95c97d..0eccc4eaf5c6 100644 --- a/django/conf/project_template/project_name/settings.py +++ b/django/conf/project_template/project_name/settings.py @@ -23,10 +23,7 @@ # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. -# On Unix systems, a value of None will cause Django to use the same -# timezone as the operating system. -# If running in a Windows environment this must be set to the same as your -# system time zone. +# In a Windows environment this must be set to your system time zone. TIME_ZONE = 'America/Chicago' # Language code for this installation. All choices can be found here: diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 6e1626011ba2..c06ef1ad3f06 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2131,8 +2131,10 @@ environment variable under the following conditions: :ref:`manually configuring settings `, or -* If you specify ``TIME_ZONE = None``. This will cause Django to fall - back to using the system time zone. +* If you specify ``TIME_ZONE = None``. This will cause Django to fall back to + using the system timezone. However, this is discouraged when :setting:`USE_TZ + = True `, because it makes conversions between local time and UTC + less reliable. If Django doesn't set the ``TZ`` environment variable, it's up to you to ensure your processes are running in the correct environment. From 6c5933d1753f223f69dab4b2c6665d7628da349c Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 29 Mar 2012 15:11:14 +0000 Subject: [PATCH 006/367] [1.4.X] Make auth test pass even when LANGUAGE_CODE is not 'en'. Refs #17980. Thanks wassup for the report. Backport of r17811 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17819 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/auth/tests/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/django/contrib/auth/tests/forms.py b/django/contrib/auth/tests/forms.py index 2397b52aff52..3c890d4c8a8f 100644 --- a/django/contrib/auth/tests/forms.py +++ b/django/contrib/auth/tests/forms.py @@ -9,6 +9,7 @@ from django.test.utils import override_settings from django.utils.encoding import force_unicode from django.utils import translation +from django.utils.translation import ugettext as _ class UserCreationFormTest(TestCase): @@ -333,6 +334,6 @@ def test_unusable_password(self): form = PasswordResetForm(data) self.assertFalse(form.is_valid()) self.assertEqual(form["email"].errors, - [u"The user account associated with this e-mail address cannot reset the password."]) + [_(u"The user account associated with this e-mail address cannot reset the password.")]) PasswordResetFormTest = override_settings(USE_TZ=False)(PasswordResetFormTest) From 515b3b85ede46f99686e3ea18fabda71acce13b7 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 29 Mar 2012 15:12:43 +0000 Subject: [PATCH 007/367] [1.4.X] Added missing indentation in models topic documentation. Backport of r17812 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17820 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/topics/db/models.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/topics/db/models.txt b/docs/topics/db/models.txt index 9b29e1ef3af8..c2577ecc9847 100644 --- a/docs/topics/db/models.txt +++ b/docs/topics/db/models.txt @@ -366,10 +366,10 @@ form would let users select the toppings. .. seealso:: -:class:`~django.db.models.ManyToManyField` fields also accept a number of extra -arguments which are explained in :ref:`the model field reference -`. These options help define how the relationship should -work; all are optional. + :class:`~django.db.models.ManyToManyField` fields also accept a number of + extra arguments which are explained in :ref:`the model field reference + `. These options help define how the relationship + should work; all are optional. .. _intermediary-manytomany: From 814385321bead68f7f49aa3c52b79e1cffda79ad Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 29 Mar 2012 15:14:08 +0000 Subject: [PATCH 008/367] [1.4.X] Fixed #17993 -- Removed quotes around module parameter for wider compatibility. Thanks roberto@unbit.it for the report. Backport of r17813 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17821 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/howto/deployment/wsgi/uwsgi.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/howto/deployment/wsgi/uwsgi.txt b/docs/howto/deployment/wsgi/uwsgi.txt index 2e05e052272c..3ac22035448b 100644 --- a/docs/howto/deployment/wsgi/uwsgi.txt +++ b/docs/howto/deployment/wsgi/uwsgi.txt @@ -47,7 +47,7 @@ uWSGI supports multiple ways to configure the process. See uWSGI's Here's an example command to start a uWSGI server:: uwsgi --chdir=/path/to/your/project - --module='mysite.wsgi:application' \ + --module=mysite.wsgi:application \ --env DJANGO_SETTINGS_MODULE=mysite.settings \ --master --pidfile=/tmp/project-master.pid \ --socket=127.0.0.1:49152 \ # can also be a file @@ -81,7 +81,7 @@ Example ini configuration file:: [uwsgi] chdir=/path/to/your/project - module='mysite.wsgi:application' + module=mysite.wsgi:application master=True pidfile=/tmp/project-master.pid vacuum=True From 35124ae3e22e590f0fd33cac75fc5ba92c0580e9 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Fri, 30 Mar 2012 18:03:54 +0000 Subject: [PATCH 009/367] [1.4.X] Fixed #17999 -- Restored the links to examples from models documentation. Refs #17605. Backport of r17832 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17833 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/topics/db/models.txt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/topics/db/models.txt b/docs/topics/db/models.txt index c2577ecc9847..12ae98a2b714 100644 --- a/docs/topics/db/models.txt +++ b/docs/topics/db/models.txt @@ -319,6 +319,8 @@ whatever you want. For example:: For details on accessing backwards-related objects, see the :ref:`Following relationships backward example `. + For sample code, see the :doc:`Many-to-one relationship model example + `. Many-to-many relationships ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -366,10 +368,13 @@ form would let users select the toppings. .. seealso:: - :class:`~django.db.models.ManyToManyField` fields also accept a number of - extra arguments which are explained in :ref:`the model field reference - `. These options help define how the relationship - should work; all are optional. + See the :doc:`Many-to-many relationship model example + ` for a full example. + +:class:`~django.db.models.ManyToManyField` fields also accept a number of +extra arguments which are explained in :ref:`the model field reference +`. These options help define how the relationship +should work; all are optional. .. _intermediary-manytomany: @@ -555,6 +560,9 @@ can be made; see :ref:`the model field reference ` for details. .. seealso:: + See the :doc:`One-to-one relationship model example + ` for a full example. + :class:`~django.db.models.OneToOneField` fields also accept one optional argument described in the :ref:`model field reference `. From 13822974dd063a094d46e4c928fb331a738e1d0b Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Sat, 31 Mar 2012 12:12:12 +0000 Subject: [PATCH 010/367] [1.4.X] Removed documentation for SMTPConnection, which was removed at r15978. Backport of r17837 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17838 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/topics/email.txt | 26 -------------------------- docs/topics/testing.txt | 2 +- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/docs/topics/email.txt b/docs/topics/email.txt index 6bc30874ae1e..0c5f1adf8d4f 100644 --- a/docs/topics/email.txt +++ b/docs/topics/email.txt @@ -423,17 +423,6 @@ want to specify it explicitly, put the following in your settings:: EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -.. admonition:: SMTPConnection objects - - Prior to version 1.2, Django provided a - :class:`~django.core.mail.SMTPConnection` class. This class provided a way - to directly control the use of SMTP to send email. This class has been - deprecated in favor of the generic email backend API. - - For backwards compatibility :class:`~django.core.mail.SMTPConnection` is - still available in ``django.core.mail`` as an alias for the SMTP backend. - New code should use :meth:`~django.core.mail.get_connection` instead. - .. _topic-email-console-backend: Console backend @@ -607,18 +596,3 @@ the email body. You then only need to set the :setting:`EMAIL_HOST` and For a more detailed discussion of testing and processing of emails locally, see the Python documentation for the :mod:`smtpd` module. - -SMTPConnection -============== - -.. class:: SMTPConnection - -.. deprecated:: 1.2 - -The ``SMTPConnection`` class has been deprecated in favor of the generic email -backend API. - -For backwards compatibility ``SMTPConnection`` is still available in -``django.core.mail`` as an alias for the :ref:`SMTP backend -`. New code should use -:meth:`~django.core.mail.get_connection` instead. diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt index 829e059181d8..2d834d31bdc8 100644 --- a/docs/topics/testing.txt +++ b/docs/topics/testing.txt @@ -2168,7 +2168,7 @@ utility methods in the ``django.test.utils`` module. Performs any global pre-test setup, such as the installing the instrumentation of the template rendering system and setting up - the dummy ``SMTPConnection``. + the dummy email outbox. .. function:: teardown_test_environment() From aafa73db546683edbaefdd17859f2722349e9373 Mon Sep 17 00:00:00 2001 From: Julien Phalip Date: Sat, 31 Mar 2012 18:46:18 +0000 Subject: [PATCH 011/367] [1.4.X] Fixed #17972 -- Ensured that admin filters on a foreign key respect the to_field attribute. This fixes a regression introduced in [14674] and Django 1.3. Thanks to graveyboat and Karen Tracey for the report. Backport of r17854 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17858 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/admin/filters.py | 5 +- django/contrib/admin/options.py | 10 +-- tests/regressiontests/admin_filters/models.py | 15 ++++ tests/regressiontests/admin_filters/tests.py | 68 ++++++++++++++++++- 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/django/contrib/admin/filters.py b/django/contrib/admin/filters.py index b64445ebfde1..76b8d30c0d38 100644 --- a/django/contrib/admin/filters.py +++ b/django/contrib/admin/filters.py @@ -155,7 +155,10 @@ def create(cls, field, request, params, model, model_admin, field_path): class RelatedFieldListFilter(FieldListFilter): def __init__(self, field, request, params, model, model_admin, field_path): other_model = get_model_from_relation(field) - rel_name = other_model._meta.pk.name + if hasattr(field, 'rel'): + rel_name = field.rel.get_related_field().name + else: + rel_name = other_model._meta.pk.name self.lookup_kwarg = '%s__%s__exact' % (field_path, rel_name) self.lookup_kwarg_isnull = '%s__isnull' % field_path self.lookup_val = request.GET.get(self.lookup_kwarg, None) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 73c2958103f9..2071792bdbf1 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -245,7 +245,7 @@ def lookup_allowed(self, lookup, value): # if foo has been specificially included in the lookup list; so # drop __id if it is the last part. However, first we need to find # the pk attribute name. - pk_attr_name = None + rel_name = None for part in parts[:-1]: try: field, _, _, _ = model._meta.get_field_by_name(part) @@ -255,13 +255,13 @@ def lookup_allowed(self, lookup, value): return True if hasattr(field, 'rel'): model = field.rel.to - pk_attr_name = model._meta.pk.name + rel_name = field.rel.get_related_field().name elif isinstance(field, RelatedObject): model = field.model - pk_attr_name = model._meta.pk.name + rel_name = model._meta.pk.name else: - pk_attr_name = None - if pk_attr_name and len(parts) > 1 and parts[-1] == pk_attr_name: + rel_name = None + if rel_name and len(parts) > 1 and parts[-1] == rel_name: parts.pop() if len(parts) == 1: diff --git a/tests/regressiontests/admin_filters/models.py b/tests/regressiontests/admin_filters/models.py index 2fa6e6631dc0..deb8ee88c359 100644 --- a/tests/regressiontests/admin_filters/models.py +++ b/tests/regressiontests/admin_filters/models.py @@ -13,3 +13,18 @@ class Book(models.Model): def __unicode__(self): return self.title + + +class Department(models.Model): + code = models.CharField(max_length=4, unique=True) + description = models.CharField(max_length=50, blank=True, null=True) + + def __unicode__(self): + return self.description + +class Employee(models.Model): + department = models.ForeignKey(Department, to_field="code") + name = models.CharField(max_length=100) + + def __unicode__(self): + return self.name \ No newline at end of file diff --git a/tests/regressiontests/admin_filters/tests.py b/tests/regressiontests/admin_filters/tests.py index b42dfd700d7e..d87c447e3020 100644 --- a/tests/regressiontests/admin_filters/tests.py +++ b/tests/regressiontests/admin_filters/tests.py @@ -4,7 +4,6 @@ from django.contrib.admin import (site, ModelAdmin, SimpleListFilter, BooleanFieldListFilter) -from django.contrib.admin.options import IncorrectLookupParameters from django.contrib.admin.views.main import ChangeList from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User @@ -13,7 +12,7 @@ from django.test.utils import override_settings from django.utils.encoding import force_unicode -from .models import Book +from .models import Book, Department, Employee def select_by(dictlist, key, value): @@ -113,6 +112,9 @@ class DecadeFilterBookAdminParameterEndsWith__In(ModelAdmin): class DecadeFilterBookAdminParameterEndsWith__Isnull(ModelAdmin): list_filter = (DecadeListFilterParameterEndsWith__Isnull,) +class EmployeeAdmin(ModelAdmin): + list_display = ['name', 'department'] + list_filter = ['department'] class ListFiltersTests(TestCase): @@ -633,4 +635,64 @@ def test_parameter_ends_with__in__or__isnull(self): choices = list(filterspec.choices(changelist)) self.assertEqual(choices[2]['display'], u'the 1990\'s') self.assertEqual(choices[2]['selected'], True) - self.assertEqual(choices[2]['query_string'], '?decade__isnull=the+90s') \ No newline at end of file + self.assertEqual(choices[2]['query_string'], '?decade__isnull=the+90s') + + def test_fk_with_to_field(self): + """ + Ensure that a filter on a FK respects the FK's to_field attribute. + Refs #17972. + """ + modeladmin = EmployeeAdmin(Employee, site) + + dev = Department.objects.create(code='DEV', description='Development') + design = Department.objects.create(code='DSN', description='Design') + john = Employee.objects.create(name='John Blue', department=dev) + jack = Employee.objects.create(name='Jack Red', department=design) + + request = self.request_factory.get('/', {}) + changelist = self.get_changelist(request, Employee, modeladmin) + + # Make sure the correct queryset is returned + queryset = changelist.get_query_set(request) + self.assertEqual(list(queryset), [john, jack]) + + filterspec = changelist.get_filters(request)[0][-1] + self.assertEqual(force_unicode(filterspec.title), u'department') + choices = list(filterspec.choices(changelist)) + + self.assertEqual(choices[0]['display'], u'All') + self.assertEqual(choices[0]['selected'], True) + self.assertEqual(choices[0]['query_string'], '?') + + self.assertEqual(choices[1]['display'], u'Development') + self.assertEqual(choices[1]['selected'], False) + self.assertEqual(choices[1]['query_string'], '?department__code__exact=DEV') + + self.assertEqual(choices[2]['display'], u'Design') + self.assertEqual(choices[2]['selected'], False) + self.assertEqual(choices[2]['query_string'], '?department__code__exact=DSN') + + # Filter by Department=='Development' -------------------------------- + + request = self.request_factory.get('/', {'department__code__exact': 'DEV'}) + changelist = self.get_changelist(request, Employee, modeladmin) + + # Make sure the correct queryset is returned + queryset = changelist.get_query_set(request) + self.assertEqual(list(queryset), [john]) + + filterspec = changelist.get_filters(request)[0][-1] + self.assertEqual(force_unicode(filterspec.title), u'department') + choices = list(filterspec.choices(changelist)) + + self.assertEqual(choices[0]['display'], u'All') + self.assertEqual(choices[0]['selected'], False) + self.assertEqual(choices[0]['query_string'], '?') + + self.assertEqual(choices[1]['display'], u'Development') + self.assertEqual(choices[1]['selected'], True) + self.assertEqual(choices[1]['query_string'], '?department__code__exact=DEV') + + self.assertEqual(choices[2]['display'], u'Design') + self.assertEqual(choices[2]['selected'], False) + self.assertEqual(choices[2]['query_string'], '?department__code__exact=DSN') From 456d4db251ac8b04f10d7f09eb711f5cc9a73e8d Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sun, 1 Apr 2012 17:17:21 +0000 Subject: [PATCH 012/367] [1.4.X] Fixed #18045 -- Corrected the documented default value of SESSION_COOKIE_HTTPONLY setting. Missing bit of r17135. Backport of r17862 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17863 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/ref/settings.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index c06ef1ad3f06..1dabdaeb1062 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1711,7 +1711,7 @@ domain cookie. See the :doc:`/topics/http/sessions`. SESSION_COOKIE_HTTPONLY ----------------------- -Default: ``False`` +Default: ``True`` 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 @@ -1725,6 +1725,9 @@ protected cookie data. .. _HTTPOnly: http://www.owasp.org/index.php/HTTPOnly +.. versionchanged:: 1.4 + The default value of the setting was changed from ``False`` to ``True``. + .. setting:: SESSION_COOKIE_NAME SESSION_COOKIE_NAME From 61b13444c50c5f1198b14eb6053c396ec8622ca4 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Mon, 2 Apr 2012 19:52:32 +0000 Subject: [PATCH 013/367] [1.4.X] Fixed #18009 -- Cleaned up a comment about removal of the old contrib.syndication Feed class. Thanks Keryn Knight for the report. Backport of r17866 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17867 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/ref/contrib/syndication.txt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/ref/contrib/syndication.txt b/docs/ref/contrib/syndication.txt index ae48914922cd..754ac5843b51 100644 --- a/docs/ref/contrib/syndication.txt +++ b/docs/ref/contrib/syndication.txt @@ -24,11 +24,7 @@ The high-level framework .. versionchanged:: 1.2 The high-level feeds framework was refactored in Django 1.2. The - pre-1.2 interface still exists, but it has been deprecated, and - will be removed in Django 1.4. If you need to maintain an old-style - Django feed, please consult the Django 1.1 documentation. For - details on updating to use the new high-level feed framework, see - the :ref:`Django 1.2 release notes <1.2-updating-feeds>`. + pre-1.2 interface has been removed in Django 1.4. Overview -------- From 9a3e9c27c277e482908b43692b1f1b200d69fee3 Mon Sep 17 00:00:00 2001 From: Ramiro Morales Date: Fri, 6 Apr 2012 18:59:06 +0000 Subject: [PATCH 014/367] [1.4.X] Fixed #18074 -- Fixed description of dumpdata command --database option. Thanks aruseni for the report. Backport of r17873 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17874 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/ref/django-admin.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 5ea72803fb32..7650951e222a 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -215,7 +215,7 @@ model names. .. versionadded:: 1.2 The :djadminopt:`--database` option can be used to specify the database -onto which the data will be loaded. +from which data will be dumped. .. django-admin-option:: --natural From a6ba67ffd1ddaf6835572d5b10fcfbe6532f91de Mon Sep 17 00:00:00 2001 From: Julien Phalip Date: Mon, 9 Apr 2012 04:32:42 +0000 Subject: [PATCH 015/367] [1.4.X] Fixed #18086 -- Restored '-pk' as the default order in the admin changelist. This rectifies a slight change in behavior introduced in Django 1.4 and r17635. Backport of r17881 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17882 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/admin/views/main.py | 2 +- .../admin_changelist/models.py | 2 +- .../regressiontests/admin_changelist/tests.py | 38 +++++++++---------- tests/regressiontests/admin_filters/tests.py | 2 +- tests/regressiontests/admin_views/tests.py | 8 ++-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py index b11f9d566b93..9d5c30434d73 100644 --- a/django/contrib/admin/views/main.py +++ b/django/contrib/admin/views/main.py @@ -258,7 +258,7 @@ def get_ordering(self, request, queryset): if not (set(ordering) & set(['pk', '-pk', pk_name, '-' + pk_name])): # The two sets do not intersect, meaning the pk isn't present. So # we add it. - ordering.append('pk') + ordering.append('-pk') return ordering diff --git a/tests/regressiontests/admin_changelist/models.py b/tests/regressiontests/admin_changelist/models.py index 6c0b78982afe..1c615eab1949 100644 --- a/tests/regressiontests/admin_changelist/models.py +++ b/tests/regressiontests/admin_changelist/models.py @@ -69,7 +69,7 @@ class UnorderedObject(models.Model): class OrderedObjectManager(models.Manager): def get_query_set(self): - return super(OrderedObjectManager, self).get_query_set().order_by('-number') + return super(OrderedObjectManager, self).get_query_set().order_by('number') class OrderedObject(models.Model): """ diff --git a/tests/regressiontests/admin_changelist/tests.py b/tests/regressiontests/admin_changelist/tests.py index d2b017b258b6..53c230073472 100644 --- a/tests/regressiontests/admin_changelist/tests.py +++ b/tests/regressiontests/admin_changelist/tests.py @@ -446,35 +446,35 @@ def test_deterministic_order_for_unordered_model(self): class UnorderedObjectAdmin(admin.ModelAdmin): list_per_page = 10 - def check_results_order(reverse=False): + def check_results_order(ascending=False): admin.site.register(UnorderedObject, UnorderedObjectAdmin) model_admin = UnorderedObjectAdmin(UnorderedObject, admin.site) - counter = 51 if reverse else 0 + counter = 0 if ascending else 51 for page in range (0, 5): request = self._mocked_authenticated_request('/unorderedobject/?p=%s' % page, superuser) response = model_admin.changelist_view(request) for result in response.context_data['cl'].result_list: - counter += -1 if reverse else 1 + counter += 1 if ascending else -1 self.assertEqual(result.id, counter) admin.site.unregister(UnorderedObject) - # When no order is defined at all, everything is ordered by 'pk'. + # When no order is defined at all, everything is ordered by '-pk'. check_results_order() # When an order field is defined but multiple records have the same - # value for that field, make sure everything gets ordered by pk as well. + # value for that field, make sure everything gets ordered by -pk as well. UnorderedObjectAdmin.ordering = ['bool'] check_results_order() # When order fields are defined, including the pk itself, use them. UnorderedObjectAdmin.ordering = ['bool', '-pk'] - check_results_order(reverse=True) - UnorderedObjectAdmin.ordering = ['bool', 'pk'] check_results_order() + UnorderedObjectAdmin.ordering = ['bool', 'pk'] + check_results_order(ascending=True) UnorderedObjectAdmin.ordering = ['-id', 'bool'] - check_results_order(reverse=True) - UnorderedObjectAdmin.ordering = ['id', 'bool'] check_results_order() + UnorderedObjectAdmin.ordering = ['id', 'bool'] + check_results_order(ascending=True) def test_deterministic_order_for_model_ordered_by_its_manager(self): """ @@ -491,32 +491,32 @@ def test_deterministic_order_for_model_ordered_by_its_manager(self): class OrderedObjectAdmin(admin.ModelAdmin): list_per_page = 10 - def check_results_order(reverse=False): + def check_results_order(ascending=False): admin.site.register(OrderedObject, OrderedObjectAdmin) model_admin = OrderedObjectAdmin(OrderedObject, admin.site) - counter = 51 if reverse else 0 + counter = 0 if ascending else 51 for page in range (0, 5): request = self._mocked_authenticated_request('/orderedobject/?p=%s' % page, superuser) response = model_admin.changelist_view(request) for result in response.context_data['cl'].result_list: - counter += -1 if reverse else 1 + counter += 1 if ascending else -1 self.assertEqual(result.id, counter) admin.site.unregister(OrderedObject) - # When no order is defined at all, use the model's default ordering (i.e. '-number') - check_results_order(reverse=True) + # When no order is defined at all, use the model's default ordering (i.e. 'number') + check_results_order(ascending=True) # When an order field is defined but multiple records have the same - # value for that field, make sure everything gets ordered by pk as well. + # value for that field, make sure everything gets ordered by -pk as well. OrderedObjectAdmin.ordering = ['bool'] check_results_order() # When order fields are defined, including the pk itself, use them. OrderedObjectAdmin.ordering = ['bool', '-pk'] - check_results_order(reverse=True) - OrderedObjectAdmin.ordering = ['bool', 'pk'] check_results_order() + OrderedObjectAdmin.ordering = ['bool', 'pk'] + check_results_order(ascending=True) OrderedObjectAdmin.ordering = ['-id', 'bool'] - check_results_order(reverse=True) + check_results_order() OrderedObjectAdmin.ordering = ['id', 'bool'] - check_results_order() \ No newline at end of file + check_results_order(ascending=True) \ No newline at end of file diff --git a/tests/regressiontests/admin_filters/tests.py b/tests/regressiontests/admin_filters/tests.py index d87c447e3020..e2a12c966339 100644 --- a/tests/regressiontests/admin_filters/tests.py +++ b/tests/regressiontests/admin_filters/tests.py @@ -654,7 +654,7 @@ def test_fk_with_to_field(self): # Make sure the correct queryset is returned queryset = changelist.get_query_set(request) - self.assertEqual(list(queryset), [john, jack]) + self.assertEqual(list(queryset), [jack, john]) filterspec = changelist.get_filters(request)[0][-1] self.assertEqual(force_unicode(filterspec.title), u'department') diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index 12f1c677af73..bc66c9bff603 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -1894,13 +1894,13 @@ def test_list_editable_pagination(self): UnorderedObject.objects.create(id=2, name='Unordered object #2') UnorderedObject.objects.create(id=3, name='Unordered object #3') response = self.client.get('/test_admin/admin/admin_views/unorderedobject/') - self.assertContains(response, 'Unordered object #1') + self.assertContains(response, 'Unordered object #3') self.assertContains(response, 'Unordered object #2') - self.assertNotContains(response, 'Unordered object #3') - response = self.client.get('/test_admin/admin/admin_views/unorderedobject/?p=1') self.assertNotContains(response, 'Unordered object #1') + response = self.client.get('/test_admin/admin/admin_views/unorderedobject/?p=1') + self.assertNotContains(response, 'Unordered object #3') self.assertNotContains(response, 'Unordered object #2') - self.assertContains(response, 'Unordered object #3') + self.assertContains(response, 'Unordered object #1') def test_list_editable_action_submit(self): # List editable changes should not be executed if the action "Go" button is From 8adfdf08de76e1d2ceb2ce073a1ff97d68f94b44 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Mon, 9 Apr 2012 10:03:31 +0000 Subject: [PATCH 016/367] [1.4.X] Fixed #17672 -- Precised MacPorts GeoDjango install instructions to install gdal with geos support. Thanks chosak for the report and the patch. Backport of r17883 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17884 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/ref/contrib/gis/install.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/contrib/gis/install.txt b/docs/ref/contrib/gis/install.txt index a5c87864c942..9cb945f76a21 100644 --- a/docs/ref/contrib/gis/install.txt +++ b/docs/ref/contrib/gis/install.txt @@ -938,7 +938,7 @@ Summary:: $ sudo port install geos $ sudo port install proj $ sudo port install postgis - $ sudo port install gdal + $ sudo port install gdal +geos $ sudo port install libgeoip .. note:: From 01dfe35b38b26137165f28b7821c6a6178956bc1 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Tue, 10 Apr 2012 06:06:14 +0000 Subject: [PATCH 017/367] [1.4.X] Fixed #18090 -- Applied filters when running prefetch_related backwards through a one-to-one relation. Backport of r17888 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17889 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/db/models/fields/related.py | 2 +- tests/modeltests/prefetch_related/tests.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index e23c7dc9b0cd..7034d348675e 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -239,7 +239,7 @@ def get_query_set(self, **db_hints): def get_prefetch_query_set(self, instances): vals = set(instance._get_pk_val() for instance in instances) params = {'%s__pk__in' % self.related.field.name: vals} - return (self.get_query_set(instance=instances[0]), + return (self.get_query_set(instance=instances[0]).filter(**params), attrgetter(self.related.field.attname), lambda obj: obj._get_pk_val(), True, diff --git a/tests/modeltests/prefetch_related/tests.py b/tests/modeltests/prefetch_related/tests.py index b0bc4359415b..f48630ad788c 100644 --- a/tests/modeltests/prefetch_related/tests.py +++ b/tests/modeltests/prefetch_related/tests.py @@ -1,7 +1,9 @@ from __future__ import with_statement, absolute_import from django.contrib.contenttypes.models import ContentType +from django.db import connection from django.test import TestCase +from django.test.utils import override_settings from .models import (Author, Book, Reader, Qualification, Teacher, Department, TaggedItem, Bookmark, AuthorAddress, FavoriteAuthors, AuthorWithAge, @@ -356,10 +358,15 @@ def test_parent_link_prefetch(self): with self.assertNumQueries(2): [a.author for a in AuthorWithAge.objects.prefetch_related('author')] + @override_settings(DEBUG=True) def test_child_link_prefetch(self): with self.assertNumQueries(2): l = [a.authorwithage for a in Author.objects.prefetch_related('authorwithage')] + # Regression for #18090: the prefetching query must include an IN clause. + self.assertIn('authorwithage', connection.queries[-1]['sql']) + self.assertIn(' IN ', connection.queries[-1]['sql']) + self.assertEqual(l, [a.authorwithage for a in Author.objects.all()]) From 8ed9e9074c9841c0e5e6b35d659fdc7c654eea93 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Tue, 10 Apr 2012 20:02:56 +0000 Subject: [PATCH 018/367] =?UTF-8?q?[1.4.X]=20Fixed=20#18095=20--=20Added?= =?UTF-8?q?=20missing=20'cc'=20mention=20in=20EmailMessage=20recipients()?= =?UTF-8?q?=20description.=20Thanks=20St=C3=A9phane=20Raimbault=20for=20th?= =?UTF-8?q?e=20report=20and=20the=20patch.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of r17891 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17892 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- docs/topics/email.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/email.txt b/docs/topics/email.txt index 0c5f1adf8d4f..2069773f8a71 100644 --- a/docs/topics/email.txt +++ b/docs/topics/email.txt @@ -290,8 +290,8 @@ The class has the following methods: override this method to put the content you want into the MIME object. * ``recipients()`` returns a list of all the recipients of the message, - whether they're recorded in the ``to`` or ``bcc`` attributes. This is - another method you might need to override when subclassing, because the + whether they're recorded in the ``to``, ``cc`` or ``bcc`` attributes. This + is another method you might need to override when subclassing, because the SMTP server needs to be told the full list of recipients when the message is sent. If you add another way to specify recipients in your class, they need to be returned from this method as well. From ee43524e227c13fc1dcef819fb1a37322b7ba3c9 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Wed, 11 Apr 2012 17:20:59 +0000 Subject: [PATCH 019/367] [1.4.X] Fixed #18104 -- Added missing parentheses around two-lines deprecation string. Thanks Roy Smith for the report. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17897 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/markup/templatetags/markup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/django/contrib/markup/templatetags/markup.py b/django/contrib/markup/templatetags/markup.py index b9fdce86a6a4..3743a85d9ee2 100644 --- a/django/contrib/markup/templatetags/markup.py +++ b/django/contrib/markup/templatetags/markup.py @@ -65,8 +65,8 @@ def markdown(value, arg=''): safe_mode = True else: safe_mode = False - python_markdown_deprecation = "The use of Python-Markdown " - "< 2.1 in Django is deprecated; please update to the current version" + python_markdown_deprecation = ("The use of Python-Markdown " + "< 2.1 in Django is deprecated; please update to the current version") # Unicode support only in markdown v1.7 or above. Version_info # exist only in markdown v1.6.2rc-2 or above. markdown_vers = getattr(markdown, "version_info", None) From 3f77b84489a73fd60949d3e72b6229d82ec16d1b Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Wed, 11 Apr 2012 21:27:18 +0000 Subject: [PATCH 020/367] [1.4.X] Fixed #18027 -- Removed an HTMLParser test that doesn't raise any more in recent Python versions. Thanks Arfever and Anssi Kaariainen for the report and the patch. Backport of r17900 from trunk. git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.4.X@17901 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- tests/regressiontests/test_utils/tests.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/regressiontests/test_utils/tests.py b/tests/regressiontests/test_utils/tests.py index 7f4208c0e1a3..5c0baf2e498d 100644 --- a/tests/regressiontests/test_utils/tests.py +++ b/tests/regressiontests/test_utils/tests.py @@ -422,8 +422,6 @@ def test_parsing_errors(self): self.assertHTMLEqual('', '

') with self.assertRaises(HTMLParseError): parse_html('

') - with self.assertRaises(HTMLParseError): - parse_html('LQkVY0^$S3YW~31t?5{SP{bs`Rl& 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 literal 38545 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
;sW-O;#K znA5Z0X+OAK-x;~g(}n9Fmmk$RJ{HuRW?X*PBU$HzV5NWDi~nQ}-guAY<0e1+_&sI3 zA9{$oM4rV|{h0UQuik1H6z`Gf(P<%5@P;Zzekpiw)WPT0-F7a0{CM2T=br#bOv$uM z=|oGWptw-|6JRL1l|FW{a2UUiz%@N}*pX29>!|LOZDaDq#X!XL4o4UL2UwRYPOR+g z>>2XCe_A9x#k}K6C`_43M_FjjUxNXbtIhAG59zsjoIl812tL(Wy|*8+yiv*_TAk zzU)Moci;9hlwp~EpW#2NA3NKzxC%Rn&;66R81HjIrqav2(;J4|oT3-zRNL%@!mSAY z_0R?jxiv>Rd{pu=HTpAw4f&Pw8Hn`b=AAdxh1L+sS0(yn{`1HD*u{nY=g*%kAGu}1 z7qQp$c!&TtwltnR6YiIr1DFW$X%T%g*S#Bv4iBC`4+#>5`(_+=`rN{ECgOwGn>4JT zMWI7B_UCLCi{bC8DUuI<^2v-0Ozo=QWGn>@wOS=jRn2D?-{(^(tiGM^>V?>=bv98P z)A`?>UJ1%%#q}cpY--nQnk`+6r$m?@;X!?bN;!+-hNcz83hIo0F7hl)23HNhWp-kP zFSeCL7q; zZ-?9dZcF#yp?=f7Q~XVVDJb`0=<~+11>?C)@d2#MVNR|{b+d2+Eb^xA^=P@L$mu%V zfu+_*!Cv7(s)`RumGW9>KZ_1n1Xm&=E;>JIP2*3%$Hfo~9GuY86(C9@{`Siw`CM$e z5r^V$9?E%`%UW{(ivSClVTby(v*Ok)GBAt+P}3R~=&q6%CtI%^32BA_YZLk=AYVj( z9z%NJ;N<*hNRT1ZpF781u3&ZM5vJ0zk4=Tlj^3j}ebxz%yTTLSN1zTLFbQV3b$QK5g^K~WgmVqHX@bqJJUX$NUH*wIO9XVt*{S;^zR&O-wAEsMS5u_d>}C@!_R zAf6@)rKizk>Jf6m1iBc`Ot5Y!We{kkH~N)jB7hMZx+JyH{uX-@ zl|Lt75};E*I5e*V3b+5e^tOU`x>PGRvs&GEpaHWF;M&e)%sAuU7+eiEHA)MJSPg(+ zIF_$^Y@IcM@{YE5S{og`6e>?vn>O8576n!kly>i$?A`2*_jZX;6;Js5>v9^ruHKsL zc!MPqaVur~6;Ec+Hg5NvP{meCHQwG;RP~V{U-ve#fg=~)J6p3$OSELoyeuq%?W!og zodG0g&ae@3n`t3+G@{Duo2anzJVao zp%{B11vuT#&R$wL(Xj7TUy<}`#!6x>7YiYDTBOGsH^Ym?TbG^%MMV^>bXtN;(FRd} zLI*AKIw!YYv>3ddDv{7)C9M4b{a@NjU6fa~gsuciuT)$k0J}eMFIGKy?xS9vTIn1m z1VgH(vd|i`I$88KXb6{YjFsSgE%;#jFAaa}h+-1a`G5Oa#8n#|=xQuo;RXdJJc8aQ z@x6_hFNidZg!YXxHrYxX?G9%S#zW3T9dCBM$Fd1W-I*W&rt!c4pwTOiRa+kk}a2K#fD4e&;CS2ME$$?5PA)w z+4pYW>*r=KLr_nTNbWrYpq8<+gBX9)Xlpl-Do;#r-NRRVWwd!#u)sl=@iILg{$&Q3 zI4aPGTgw1a3`6}aEqh0FnXc_^=-2uK4uQ)`d38#YcIB7$Jd&RWZd*>MZTC>8gK{|X zWhvH{YMWIT*JXT_l5(r};d250#QhPC+V?;5x49hb8ue83S42f*&#=dG>;s(4JPkS7 zK8tQlHpI`hOO+Ri*SAN@==`63u~_#jsyViH+XYt1h+}@}13viMpvhfWz#c_Ohx`YB z8)ay{e$9+QmT}I0g)oIejlyoDMpNfz!V0O86K8zBh8xm|;KMKSae8jMz)5pIq3`y&%L*^w(F;K>p-!|$P|=;mhbTUiyq9 zdV!{0C<@{U4UALOkM6egzaj#Mw$s%jq;Ccd^b~h?8lY(Hpq}f8Elv2{7^0Cs_=l%K z+fMLJ_}&@*S^-9kjOs3L>c%flCAX$qI>x8yy;V50cg=GRM^ADn~z2cuA-#>d&b`fwrAzkh{9 zN^JF2i$(6hYbQr6X&I-n@IXcQ`t3~Hq`OtZkwz`exP!x>p?4VM+-d4tmz`S2iUq2BG z8;D%}YnR#*v-OphV|38ywwz|XK-9Yye01x_l86c`nrHpPf<&d;rt*a|k8$j=r_-GHLVA6ipM*lmYer`KA8 z-wE0vp3kzJdjxZw3R+oXuE{HR$yL_9e%WaDw(|qFBR7DkYc-R^2?u^5gP~nlZG%pC zx&tb%tpD2XuDDfz136$mbfEQc5j=3*I#%Ji|2KHi~V&P<-!$+{)D6>y>J$rrYW>{64ZBE)*d9>32~p z(e}{Y_?ZdnFO79lMl0(ht@>LW=)LNaiz;UR9Bjr!2*?_d^JqsVB(;G zBeNNg`fM-w9RC3ux-d7_thILfl8noB=|u1^iLFNP<$JP4n?9M<48C5flRBX0`j1?S z?*3bjrtTCZD_@A`MndXP`xp596vfwseKZtArwr)qj1n3R@2ZRL(qG36Z0_es3-_km z5|jLptsBn|K>llXC|$B`l|e3`Z%_V*$%PgHIN#DJdwRKL&{r zadCsO#N44DW4=(HF$Sssbjq;669Qkz6PbTFN`OtJ=Xs<7=wtLl)eTQ{S=%$2g(CEI zCf`Q!+1-745^|7vB=MhfbOx)RpYL>5_(5}|?mYZYzs(Ba(OnvYM>qFF55UmV)=sy* z41xvTpZMBA6_A!lPqyuA9%4_7{s?HxR;Tq<(>~Zm0In1Ze-zXoz%U^UIv<-+JEhgM zH~TK^zA-YoAp=*As(6XEWI)<$L;L@vzsFT+K}!Bigg;kZ<5-^0!v43{hXp$HNVVk4 zeQW~<>Z+*hxOaaqYU6o{7@llL4JHFIlr$5Dy__EnHKU`OlL_}GXC87?-uMB>Uu}#m z71>`07T<7iaFGG(3y(iG_ki;MdxX2J5UY>xW}tycFIJzdHWC|%(aW#eE|!J#&Ig)6 z6xHK3;@Xsof7)qA2ElJn*kUbK9;Msbo5h%xDaW(LP8DdsDH%O~wqB+`7vcFfUts;8 zme2r?$xA0#@XpQ-6+CM3WRWqG|?&3Hh;5n4yRhDsZq!-#fm_zj(DWe^%oR z!B|)nLaS15LgkpxKj>N8Y!;r59`TAHx-mL zHc?E1U&hJQVzgM+$?_w=zH;`S6lrZ(bBM?bPeX{T`1z&Cx~e2E;sY2p`SrAXa#ECfESY?jy~l@d#wPP|w6zpZ9tva|{qJbN4B+_eYW z?Cvyrm@loo(FD|y4tPhab$)}Z?m7eM?mz-Ub>rJOUT;1fFFk^m+i;B^_FRk!h5PX{ z=LPZr0sSid>gC_)0u8<%LThUs#5pRb*m~b>g5Q^A_f#D|yC$Rs8Pn+_bV4U?X z0-UFir)$N?qcrxx?x)q%J=S3K2Zi(eHO-bu=Z{bs^GkoOzh)8J3;MbC0N(cieUNZD z5`wCjfDNO!a!vZZZ{xB_QY~Tn&-E7m#V7&JjLZR2e*DjgFaWY|C9(YEa3|Vp4ZkZH2zx9icU0kkvj4`kJVb=IA~Gf;Fcezrfa|!69VH!dr1X=E zIE)gB8@&FwZj`g2JLA~@ah>^%^+G?>Hmp}=gTP~mA9?&jKI9=d=Cmd2cxPgg`Ci95 zH(aPxyCpGN2r5nq|MCuNXD@breFy(~7Y>X(zf#Kdt+j^|*WRg3T#dTC)8Hkcx~i%# za5@idCZNJ%%h-Alp5dL?a&}tJkn(z-`=sy?6hX4w9S$=N?sWgeIj-V&cQ?+Wlht5P z^uXS?cSOW|tA(f6Bmo1%PKs#KEd_^4EM$JBq;U))4H# zfk?-v3i*A^r86^~o}&RqiPg7rXQfZk6j2s)fs+z6Tlr_RA>Tge$@yA3ue&>Ko~QeF z8d49?UhRhiD4&?C#8eA%jrKbVpAF}Gn=qJIf5j0lPS($F#jsd3xv3u9zr4QB>9g@rYH|mq^M-ScEBtQJrejqF z&`~hrp0BV`GD2{@rs#j4#nyfPRhHe?KNr)Qt;8NXag~=(ki8S&iqH*_ES=p09!m(F zMRiU-Swr($Zr0qz>j%g_qFG#Dd+Qlt+tRy(5MMYMoIZBO{Y&jjPg%9HYQJCNGwp-Ad3@b}q9XQeZHRO`TwLdZ z;_{16RZqC#X~T>t`nyC$d!A_e{tmLlV`k~&DCy%$#M;`L`8(DFjxUL$<@;Xe#T1+I z=uEc922-|_6olOGO=^UE{NyM(NM_`wnArda=4I`-B1*fjuGMQ2!71{De?jRauYlF+ zt&f|L$26WhO4oN|sI>DIN+Zd?eUx-F_Y7^`g}Z5DofpjW65XmyApB;4hv@qK%tYnH z>O>s#8H9O$VDpNDfdx}3qA%XYaS z^sXh_!Z5P8=rkq7OK%9yCjbY89!kfSURg~o%7>A0`0rokiwI0C$Eil^ZPqSVmmW68 z6+U|>uw%v^D^W4Nf-Yh4S(A2AY#wk_#Z5t1V+k*MxURW{?UH{{sJ*(x##8 z?hFO}wJTK4K0OMVe7rgMC{4a(EzZ9%D#^*o^|iPhe6%?JJ&PkeI+4tcjzjK6=s<|@ z;p@XDzW2kr$Cb=?GPakEo>j-LSV#u7nWbfE6AZnI!HX3x7@th&<(IT#Qg(K1Q4zIQ zYq@GiL$;EK_2Sdz@JGF;XItv3B<}W|w7fL=Y?cb56^?n(1kdk&`W<)ODlh&6PY`;* z{&1?H|F;Di+DSkypSV^MciBxe-{j~`3(S8&**L3!uGtLVUwAkCvqs zg8Sh|X)w-my-Eg4Z1Rc3|-!9df?wFi;K37V(@biHnm;b>#+FSTcWf+K;8c0g$9g2hT$EI=oY7$nc zUlT zJFU}p?j}*lukwmwl=AAcZ^sTlF2ODquz;5ePhB(aL+;-rB2r39Z?cI4FE2umtv+Um z4&s(^$S3&X_%|#<->Tp0{_g08v>AR^s_n1$4*i-7U$$2e5!=l!o#+T-Le0p%DNX{U zCpjSn#l_L#xYtKaANL2=-E=SdgBv4My!2!O4n#&)R^?su_mDcF<`z4*8XWUy;d==A z*?C|R*L*NktslArE~k;54Vs|&GOaCP@0=ODXuGEll=nWtSGNLV5G^1u?r1w ziTV-^II!-90EH`ZB7=R&Vcrth<5nNe9L5gCt_MZGFGuLo2`I?HOu|J@F1(gg9(uyN zWmUN5HSiI??J?y~%{MpE9U64EHJ^-|tF_?3-|L5`$qcCF;K2P+&(d;8J&mvEG6kG8 zofBtoug~tY`m%H3%&r;^jvk?a4}VI87ScnyZ62NMLarJNdn|Qd>pZNKH1c&=H(f3M zv8tPKTn0|LQ2^~YEHH-Zp#(mVeLLI0+1y}IaP!(6tk<)wLjm3i=$A$ZP2$L9mUR_M z#zOb1OapD4*12XfKBjV@R*gZCHDhM|)~^oZ^FNPBd%}vZ3>#CK4e?g>=7+Mu2aB>{ zWN6AA5)fH9utlTN|3lh;M>X|D;i4$L3xX8s2-2%Gsi8;*LArDmkS@I%nuvgufHY|a z>Ag#bfYK2ny-J7B2|bVicl$f%y!Xx<_l|MzxctY+%G%j$%{}*=-}lYA$PK|mY#wA!j1O4~8>N6wZn z>|C`|*`HeL2fUJ4DQmHjUV9F@!fVcDE1x<<}OPtO}t%12@2?Z+>pw;mW^ zZQW-aym)nmZ^O^&+%vq_M#_o2636rPYKYRKx64lN;{M)1z^iGBTt%F^=oTl_z4&&z zami@abEfsSC04{Na|)fE=firq**|G74vp#KMD? zMB*Ed2SThHPK5e-Pi;pRKO4R&8)lUNL=N>qd+ac(7v6&PSWoo^4V`R`gAlpZ{EC{% z-!{eACTv>s_unRWpN|udXHEcnr`pD)#&H`f{^*gCiYKa-q5r8CzYAjk19Yu57~ROT zUIseEU}E!9u~;M@B1^+=0j34q+5Y*{sII4l$lYdk$RQgpRPVlxL3}~M!faz`$hof% zHbKY>3OXdfp6PX3V1?FwEDh{-yUMZ>FkJq*?v%xN{GKytJWbcdGg)uDz{mQ5URArG z4cCdxKKdw-%4RRO{i{*TwFm2#OK=LWFuB6w%nvMpF!n!ofz?0x{^WwcP4&0zMYeKC ze}M*LRkU%FP5GhnXPDOlTFJ86!xGbhFqasXjR49+oX?7a#y`}m(dT{bY)uQ(w(&&9 z^gb?clGsf73xc&KhCiXRrFC+1Hty=5irthdwdn{l*0G)Y3p!-`@9o(h*G4yW{68PQ za0;@H6Y6xTgH`_lfPP+I>UdvLRz?*u-1dIU)MknF#Wxmxzqv;F#^)hjCJP@HPUMy? zXa2k2A0m8v%?Dh9O4n$eNu1FfuTTue2a)TYgR9%3-L!Yb$`|61o@a0;65(rnXh ze=$9kSJ(mh47h3O_eww5*LyzVY2-in>Zb$UI(jEwwh(BYkGaGfcF%6y%#qZ6_0w@O zC8ulqb^br@ATChl#*s=JxD8#0r&7rkX zKM*!cD?_}v>1{l~>$0V|NrdUpU%S+b%n8{0pO-z4MoxBZUTrhqmYv`Xn(B-kXh+@0a0& zefSdYh1eyKq}wWohH;zjd0qwNWPwc>WU=UNW2L{md;4_Z(+r` z{vpzf06fm$Q&-ClOR#~x47f1lZKGs#@q8QW$SJZ4}%aM+8ca8F$BHf{`4 z$TnA$tzPWfP2MxPfKUC_$iGQD^N-iz<=j7I{aGOSyiO+MG@K0d6mf97W$*RLtmt*= zp|I{q?A_I|-Vi8><>$O17nj&EzFsnf_l_Nn=I6D!H+;Ha*k~IJVKxkb;DMlm^+%!g zBcE+x?yp6~M(W_+Zx(E&;Sl|QS(I~eTgng6kg~(!dcuEC6kuEuF%1U)oZ$NwCY!F` z%uRK5266>M837FNU#CLnc``tdqeUu*F|GCc9J@?g5r2UU?(OeiH-$lMf)7%2AMM78 z*UYGna=d4HIF#ML`5v>mNovqZf$%K%z5DZ z1Ln?b-hSsIm>Dd>Jd)hCE`pXFdQ_PZp;vKmbckmm@AS)H>FH92`U7p3*o-E07NSJ} z{o4+XQ8>T=PmsbYT<6>;gQCQ-QaCdPxYWu4Bp@8`#_jcP}Mhn4! z8^Im?l46{qn!s31*L5ezrlkUx=kf-qEFN$$)4GA4tp0dxR8AekbEIXO1*!i$L2~+U z&k|2=J)8gad;oWT_ht*Ox(dscwoFY+V;2%4uXfJ>?%%qLY!6btfJl;gY;7X6&!^*9={a1A6U7 z{UlgixCE-Vq4i>Q^KqqSy$!ke^BK2uyL*FfsT1?>ehXh)Ov2~KIHLj%@#YhNg4&c$ zL@VBt_p{3gJLF)i5Hs!!aoUk3=MgtAg>XIRq)FdINa%qezjdHDgYaFQ&G{f461PSN z%lQd-(CD*%uTIv8W_K^v&Ca3VG;|f!xqeZ5cK5$bVsjsV>hZYN4*)dIIdO`iAOPpw z2eK!XAk?ewP(gk}AdsM@p?M@JNee@bV0QO7oK_%Ljx0w}gZ@?b1fv`HpHu1VeScVZ7QD+@XtSFmp{2W28REuHwDPs3 zq|<^zb=I#5Y!jjHnq}2b%!mA!1;VGXt>L%$9-|DuGy36k2sLt9{fp33(a!Wu469Ru zlJxD3!8fbdR6YA^SDmQ|ZBI1c3A^+QJy%GLJYF{s3uwwU6I%Zsa_hn$a-sde8Ur!W zpP>jvJ@af@2^UtMyWquOOlgx4wb`e}wEvJ6bJQw;vX}*?My??i5j6J!4bbB+2y+@F zJ=8paol5VW->l@@?EaGO&q;&QWO^?}q~7&)4Lm!$r}y1LAts^Iv%%+HeytnUWmZsP znpHH^?HgbxiG;U$h4AB6{Es1mH_St?Y(FWDz?Z(M@5P-Em=Zkn>e;v^cfvlgT=+0> zcSv7aRf--*pt#T=*zL~R&FM+I?m12MJ|vgfV7?9EN*e8OO(!w{5?9l5`r)eXotaL5 zdIp)jNDpL)$eY$c!vZt-4u&DzbRN5IJE&su{yR!&V`lR18dR@PaF#iu?oQbvm*7Em z>swcQxv}ADi{oU|BBXm(0nN%Xdg;;#H5ck=RM3HGRo!?mfC#4P{U0gzVf~_i3x&7M z7HA|m3oUi&dw2>!P?G=z6_n}acfg%wQih-chn$q1m+t=hJ9_6={IpRsaM>e?Wwh_6 zr7!UKvNJ;WaDZn}jW4}Ewn zEIeJlk{=1}u;yUD>979%xm^#v*!CctB1YvO1s^D|Qoo~6v8VeQTvY55V#Sgr+i_Me z-HtNkxZS~jj~x36FNVfP%L(7OnrzsV_bUTmqC_PUfmujVu9#Zio1JtM*JWobX#bX) z@Vm@k5J`sPU!m4yAam4Qy}!!Fd(Vjqo_bnHWLK05fF@9)DjtHbqn>Zmi_eDZS$S7K zw6tDyA$OFCjHN(kpE071DI#!*^MGQ z(vui;J?12fbUI*BGHkOZ1Z zspCSOdMq`1vTXkT{I)HL4a&Kppateb%LzY=D-Vw@hMOUn8`{G?t6RrqBPi}FBAEpDrJTWFruur?ms=HU4+Xq+ zPL7**=}ZqSU-lhKFN{KJ%iVhjK<5&(TQuP|tgVr8x&$D3x1YtK2&-s6V5P$098S&U z4Be}ne}u){z%(bahAx^U>|R0Ja@E|(E88w=9q#f>Q91*E*ysAUe)f0K(XvYtYG z?_N9~%42r5Gp|d}$4C5hg3l7h(v1;dmXNp<5taE&e<~nATOK4(mYuz)Dzb6L)F!1) zdN}$Q=absKpWH2gPj`Fg)>DiFrb=9}SGhwz^tp=H;AhQ^rU2}ue9XBW_8M1`XTJFX zB`3$by($;pqZ^RpnXe0*Zkg-4)H!Ur*p4(0-%U1*{L7AI2G$(5=+xR&Da~3j^Zx zl)u;CK73H7q-S{eN9<)L6(!WnVI}|XQT9#ksO)~|XzQ#(_Bmu;_~atPjK43w-f@Rn z{f)EGJ8MXfI^i?qFuZE@*ZNZ=f3*f?>yH~Y2qU;3PB-o$(vEGlj~<)(6LtgIkxCs; zXr7gGmCpR^cFW1L-88gJN=jm&U31__r9B+x?ni3cyv1^%*!TO#wAtdjWmU1OD=1Lu1RuxcJT~pKpMo>WqxRbAR>; zwge`=M6Oi~&~Mr$-I=n3^7sG8f|G&!kOwnO$8CGej_np)U5F+JzOmn3#U1`+5|M$f zFNl9_1)C!0i&s%2QQWnjd#uRVYWDl|-i)u4n(j0B;igxquy1=w30af%gtj*l-l2Bi zwfn9x?%!b^gXF;sA=q@Q{(Tf#p|_N`;ZCQW$+O3jcke04QPyG0O(0Z~#zL(BC3AOq zmV{I1@(O1Q(~JV_nt16PM0x}#A6NkORV$qI$sQg3DrXIK8HQbvqEPiGAe7*2R@pqiuthgge8vm>gHG%-dE2V!7U#?z2nzUt(hQGV(g^tJI(^C=V(d>kt zYK56FJ$G)<4IeoSy(J{`aK(SQx-X3*=1am`TbM9m_T&A}GqFc15mhh1OJ2%RjIA^BO2bWLN7;-~iKaiKY9OROw z@_i_6H3YeKva?*|cV8BmzjMY4hqq!wt8R(2EOW9u~%J4WX%BJ z1@OSFM}K=Q$iVDbZ@;rLb`z6>Y&_^lM$6<(GSd23%OqUl-tPga<7-2o zhWZA3nwdRv5MJZXT)&GnEeW=26V?juoo_eWkj7r-@jp0gXKe@uAH2m$*hiaV@e$@c zgR*wPFlWJ-zk2>FL0}o>Dof*hP^B9)5qZ2~Py9l3XGVoID?@Ft<>#q=*$YYKpYCh3 zs}CAaOPqdL*hG~F-av*9PFg=_5${B6|5j1TY=qDW|FKGZs3DExtL5;d zt@jKyPu9y|crtWlBjc*DRhDoHr(kukSJ0mYqmMUCTp-Qv8mzpL0eYjN0$0Te3rfD+ z^{I8@=4FMhu($PLg}cP+$kIN3Mr(C)=&rLirTyB%!}d6k;$E&Q0c&a1J$EY=-x(}+ zkbY66K(s>FEvRe$=Lt=et9{@b6}yrrH(#*|HY7RD$o_N&#&2yG{pmXH#2`>??U)57pxe<>;iM(&%s{#&+(y8$v@imPccl2`{eO( z&{u=KD39EF-0`XG^S`GPDxFyQ51_zG=_WnKy*ty(K$HWWtaN4n@-79E%=Mb%Q z@K%{2;ZRm67qBmf`+SXs>-%GdCz9`judS%wEyewGze40JKhhcv6?;T)-4^i8-9yHF zj!Szal#=p|1&MC(frIH$R7h=J!mqXjnYkn4>;%t}3C#}5bfM1}m>h%P_!QhmT~Nry zR8KA;#7TBZZ}y4IG-rZ)u@Ct6$>ID?pn@a%EA9^L?vNid%m`gQsD?jU!T6)RpigfD zPptv7S*nk{M#5liObI2kDtQvu6TouSCFEq>XXR!6|KUJIF9D;{^n{`0_#4w0w~HK{sc)J)3x{h z*;jXy*dLCSmFKhk5qj2;m|%t0{jTC-T^#65OvIY(pP5$_pr@nUF+0@_EKa-H%a(x1 zDoAJ=1>VmJ>m%l*D!_)42%;>hCti1GFF7;^SkSge!p< zYI!QAx7C=6N_ac+$Ax!?gHls`k#S zwSB}Y#KgfB=hj19aj8jow9jc5C6uu__~oL!a`<`2*!S+d{py6i&p!Qd?VU-wWvwen z|NIaOjJJ@xPZRy-;LLwP7mik1=x_Q`hnJ|<%f%tbr>kSp*Hg%jF~{>el8Fn`ezHsb zaw$jIvXg4?q?r9%YW)=BhnKb0Spm=Al`j{*C=I=gWHYFTM||Ho2?Pl?Nf^5!R9O`W zhCl%adyd$0a$aA*WI$4yU$)nc&ex{mSD#J5{6VjV;LtKu%|)dbu9rup%-IA;Xw~S z_s;!%G?`AjVroq}8@Tzc*}hqv|6cV$%g4)6n!JKK>qypHm+W79Z{Lb8*+oq0Eh$7E zaR2n!n3Mqf@=@Dd^*<7lr-6b!s1$D5h>GDheUN4QP}m-XwCQNYPlq~02n6kkT2@>A znQDRhHTWvK`5S;>OJP102UC5{+VM|(yw~P_GbPhWp71L&cINoJH>LL~naZG8{sO zJREaD!oGx&cF!Qw!0l00_T~1Wm_vF`0q%sM{)^WT z?aJhNHAAaCGB6bt)rLdS@6T<`C}H>B0@=7NRR-AU9*}uz4$2IgJ4=eWY(Vtt%_ErmP%JcyLz3YI#VFk4z8M@{?RW5u>((*%lkA*5Dt>o&w!w)}#vDYE@L}mfLiw~Wg1D5n!j?e$w3{X9H1ETmE;0bIz2A6%|X&^QcsS0po*}eiUTzC#x^T1 zTDnHl$^-Mf%bE~5_wngdK0~{#Df~e{N+tpj41hj`6P9u_3}ll*HBvCYGVO*+LPVp} zyC3P73ZR!!`PFk5H=xR6v(S|XL7SOh`wZM2{MrAbUxx8n4_x?DOijt;zy5gt!TTst ze{uQ{MU8lW&+5%ei^W3OPd)vsU~C1Pu+L>Gc+q;{$M~gkR6V^jID(Z_j|;arc=PYm zvurvH@%0=`DnGVQZLWOE`D`eCO+da_qbh&kmtU0*eS78LRkU_zL;=&nMZxyoo32m< z#3`?`U66j*tyukLr3#{*m~9;vFx|O8re+}OtIVW>66XrFPAKjl+|x^f2y<{OyJb<| z4ZPMG;dz7Ren*idoarKgI)gOd(&OF=sQ)1 zE33&SBgP=YV!D~$OL--P-(NuD@#<&}5y|+Z*3sEn*}@{r@PCjzbg0xY$b`RN{77`V z?1|Dx*k-1}??i=(sOtO|*5+kD;Wv^-lj8XmW%~GRoH#wb#obaw#57BsEicRg2iZ8m zS`F2*d2q10QZ{A#7rM{7Xi5!R1@#2Sps6nf4{aA`8vbxtYYoj@hyaNCP;e?*;2&>0 zx-_;)4o)FT%mEL-kkzD3``S4C97Wk~yFchtN8=>umdgBmMY-()P7JZBCj!_RoDH;bgVfWJb@)0Gay$GRy^ME8V(fhLH0QZo8(V_Xs zgLSR*aahz9?IjQ%FCrTL<<#y!vVk=l-2UmEw!d4qBRz z8jtmQfk!9-M|QCoV2pwj(BG(bJU_CSjZ#y)P`R&cM zv03b&L%9Du`-hD_N+JR;ss%R~T8|cWq-;Ytu!xCv8E7L?sP2t_rm%bbC7qSIICyP}!4i|9Z21?^=`p{t1S%jOf9W^Cd_pI#>3t6Mv{?uB|C@Xb zGSs%;T+J6!j;dXo(c5iPW3S{4^a3DfIe_1zKwrj0%ib9T($ex7kHAfd%qqWZZ4JGy zO?mCtXf)s|)csqBxTrWbk_9C2VbCbLv=ql!E&n;eGf+%JK?5lNob%9Pv7b70y|X{~ z#j*Nf)}lI1s4>QLZ{mNyQY$X2-bz0`7VrH6{73kosE@;ARm`=_p<~xy)@(k*q;V*e z{wFeRR(t9~NT!E9K>4vj1R$}5kNFZ_R>4y8gVk^c;%06`7bg)YUWTVIEw_UoHivrc8n z1rqLW5Z_PI;7{UL@ZL)HdCd-NzQ!ICI5YFz>e1b;@G;~L6*@MRdN!NAET{kBFq|rG zL?((&@B6l??cg7&Xn!s36vcUN5xCOmcvy>(FN4ZU*@O_(U|FxkS?t0JW{ zKMIo1GpEFiFL_DWJg;bwFDYHjcl`5U=GU8oV*9lKMbSnkO%)B7jM#RXZI_M+#^W8S z?eu6t?mCg$A-7WPFAINOHWFNOvbLF703+4Te+~WctYXpkIewHh&4SYLu~hA<^{+Xa z)ju26hdn?6%`M28KdZj56Fj`k_#-MFF}+IP01>IZTliY_SF3;dZh{oGO6vaV=r)?> zgWe&8W@iJidyt9@$Nj|`Y)0|ldOklJ!+^t`%l+7T@LtwsL1paYX@y)0s6tfJ5l8sXBDLdYLfZl7XvB2 zp$~;IK4Q09tkzv~D6jT`lM_*XY*wdNI?w8j#R3o-5tnHba}RByZNqgQA|m(bJ7@$t zs3fE_W`hl4?sIa86k~aN&0XY?xhmv*+Q^h8N>S>Oy-2tx@>bhJUgcAHm(NGFqSu!@ zMvr4#uU=Vwiu)+;I->&Ri{g`$;XROMQI7hdA#+&R<6TZl10RP2|Ji)C~H!2|B(LKNx#Z>6Z$rFj>BiSlHnrVy&K4U zj;DwR8pdY5ZJ38)cJ6(og{=IISbj77jYBFpA1lUJ?K`9s6niA5zHp(6^&z$&d6VuQ zEy_>G8Wrxz&f2zlATa?J(VHUMqS1J}vX=fWYDk80v1fQCj|}sshFr@So69y=IVGMV zC7kOq>EI51)UwAT65EQ(0w0W!lvMTZGKNZsR~R@&c%W&;^~ zi0#TW;C3tVNS?7$FkyeHa?SMBo3PN|n=*y%SAMM`=&4h*@NmSk)5w+K1nSm{{uLKc zasfQRy4?<9xX@e%w~yS!QGpxWJeh_4>&Pwt#6)CaGy#4MYhA~j^=yGkv}8xb8*Gip z=vk*&hJ$u6Em~Z^Aege&hXs)N;X(&BnQv#{?i_5PWav?@72(^%<(k*)dqjc{6&1DQ zZ%^)SWN%+jFiM503!h-F%470=mxaS!lUl8CSh*EnS9Z;+@RCp&9E_*5gLcO0%G9!| zbSvlBb{tqN`*<{SG1mIo}lwj0p)+7v;~rjQL)~L_GP1l(y-0 zOnVi&7Uzj5(Ur)`j--i@OyA!mndu9Xl)j>p&DPlQ&Fj;tfoY}xD5`!=)MD*|^@B6f zh)m%pPdHUSO1jQRW-jUIj4VcW-tz69V_LsO-DmQ>TLHS zWfQqVD0{7!`gL+x`nOTETM64FQ%;4j%?8OtNEzc z?GGyZZQ4_7TUTs^YQiLNnrKP@uL^BWz5xtN1T= ztBOrRYHnzW5k6hN1ZZm$XZ+)$8a>JD%AV{W=k!8RiWK15y%mub#BQa3)+Q#Xo< z7;iD|-IRO>ulaX{)p7XLfTsm)E_?S2EpMzw0*;uW);zNcQbN(<@_REUOKHD#*A^Bb zGU-%zDwG!x2%am1!$rFh)fd$3ujcV|w^@Ex2N1B@{O*O`tSldg-kuwy?8sMr#>jNH z6sWPzF45X1WT2tk-Sq6(jcg_q{ygmhEtzp^%)H(A1|hu;<|TFOhCN#Fi_dKKPmXrP zeJQI1JIH4A=6ClF+c_f4sJ#9PcznweQuTJA6y1;z50SodJQAeTrz!2Udt%=|GfH+& zo8Ka&{_Yt8pIM8asW1PHEgd~y|M`R#H5X&5TV#pAx{HTTEIa7%B%$q~pTw@`EqjJl z%BQ~1#!?$E3Vwcnd6)kz*aBO0ygqTk0^86b!R@<`SL3|@Pzq*wyyCPX0B%JKRmRm{w4yMpX458u8|etPoPce&l zy>enj3Oa?@(4z#-!A!Q&Tc}A>yP>=vVsay{#_U3yA|pW;anSEYV55t)SK}|kYro^5 z1}QT>JVBqll|*HI-2$2`BPDf3Gb&U0NB62&5pVfbT2DJGQ^vE;k}{@#q0>7{fR{4d zdHSiCaWwt;;kt6u3?ifSi$w4ZSLGArs(axr=Je7-PLBDwgP;HcQn{XeR&acmVL*>n z_=K~j!JU2L`0D#*4`YSt%+)ZZGadb)V$=DUY!yg4P`{^Fwk52S4e$dEeceEGcM7QV zVZ|k7e1I7EEFTiGX(C7Qk0ot9)g!0*UZ)oIAyYZd%(V2hf@b?j(C&PzK8W-WNG*x@ zUppP-Bl($?{4O{uKxlXO-O5r2Pw|?Q97J~e5rP{~p#YxLX(R%YHx=N-A*}$62h~m==y$A07cByCyC|hv|SU+=Vi~7AgVWD&RT5a2$PGs4{4*G zlYY9dH=KTWj>lcx@_s+0JY9G1M^QU3^o{*-_Y8T5j`HBx(ac%kwX>go#9ohkkW2~> z6PDm$E2E;z8}j%Saa(&S(W;mX{5Uups=*hscE*ZZ>%I)y|JycG`k!{KYT&9r%45Tk zs!__(v^`|R$7lJ5Z>$JnYx;-l90kO$O>Wg1Ll1ndJKU|O8q<0lqJxANp=qH0L1;Pa z8*qWm;j*ZY!f{Bpnu)<`-f8=$Ba4H^bE;PlB=B3ACK`VrR4#- z_W=s?WQ7T6)PzK?KKnZI7T9p$Pij5XFLEWnKP2!znTDHMUKNdJ%ZNrB%rB2W_}--o z`l7cI51g_-+{n0}62AY1jHkSC*9aZNPn8O%zz3(K__37-oe`^pe>Zzt6XZMyj4q$d zZw(|0EF2*ss%&6fF%8CIWl}QHCnU+tq3Gin@y)YBL-8E-64ny;Nrhe?5u;=ICAJ@H z$frmjdeXnQ$tM+eigCGflvr8@!dhy##hBZ%{SX*3VsxaZNI6eJ36a7y-u>VG0=)a&A`s_sb&N-^V z+=|}s>g~#lmtxnb##1_+gZVSJL zp#Gke7ibC_YzeivO&blbZ?HA3^gZw7OVVCfg(9ZYf=*^;UX~|Kg&o116Q}-gc4k;SpbY;a)#wTp|(rdIV-=Li` z!~t?P_;DFK@4|DRB%wbAh202a0+0h=foDE&z!`2*N6B{euYSf$_Rtr%-TL0l)$iJb z!qmEk`rIigyWd4qx_KZKc=8zNs`N^n;dPKBRbqbX zpxuZ657942f|@ldty0=?DXG^c<*9?>Oi<{4FLWTn?o6Pw-(la+k`*E#!56^!%q0#j z#O&tYx8%lEg8lW0xLFw_zzWQ5F>>n~7-(*HryC+j|%=l_`%iEWL7WDA8u(B9?O zF`0#qJI^J{NG~HdoBz9Pg!)V*49=LDJdSTXxs!7 zaF%s7U7*U6Iu7f_Ugz?V(3VM@j`I;oE&CIrt3u0YTHo&_II`wW!b5Z82+spmS@^1V zyw|1IM9tPG@W{BRK z?C$*emjURI@oQ*rlPFOPs9r*uC(>2dM;ZJru|UohVTHxw_xbfdIZ)SH4DU(o zG_@cB>*d}c@D4R!Vt_(|AN-N}uJS>A#qc{mH1>^5g=uL%e{68tNh=<7>@8TJ`Von4 znIXri!L6-hiUJ+sz=X+z1FDZ5jdW2knNK(FZizEEnu-l$P1!}Q(_$#gRv+Syj*k8G zBci^(zMh736M&E0+u7;f98PDY1n(3C;%MFTd+lfQr0{zS4@5Q$9$g=M zLA8=^_Hh@qCdSzn7coCDIgV=0wPuX~_evf}YO} zHE}dy5L-MAY^bX=-=9YbdQ6Cd^|e*c$9;6IIX~&0TwWAMQ7s7Qve5mhbu6o!=S4}m z7q>ME$oSuqJO;`T-aBQ)M#I;`cq-e=frfR-n=Q*x8`|fGz2D(awYpA$nUtmQa>n6LrMr>){u&?;1RKehS;1W(JI!0vlq9>W#q6i*fXEBhkK+IaPVT_P~Beky8e zpB*9Huezw){zwBY?A+wyfx-$?6421~Jt#_MQKRyi%C5Z1#)TL{k}a%SUP}K{MV@mT zex0+FLMM&cZf2s9iV2e>gsS5#=HeyRE!*KZ7-JAQS#1u}-kOW%F+8(2A~n4776 zDW=x>JG31b(!r?`LQ?~6)#~4>$gyjgw_oJTqz;t1nz-K}NPnCsuOs!R1tU$?3>?eQ z5+E%Laje&ufjym-e2E@iItPk_Hz7To#_UPLZ<$$X`m<-`!at#hdD^9*uGXsqLJ07)8{s(cH|?${uuM>0dl};uEhI zgNq{5T8Q9hQC#*@?;86wqby-)l|^aDk;+THT&m{QVED!yNq=~xq?+8|SBzRSNh+Rr z0t5P#l~y$=7ZOp!7u-{dI)qM2fBfP(gsQpcOiE_%nmeo{CnmD7%Yq+?!D3xrzg7|) zzS~*|_+K0;9`q5yh2bkM?NG#vj~H!9eyv+y=4tF;hbXu1Jz{2|kM;|K0jWZZjivXU zR($bTqkb@&=kTZ~!G2j;#RU1X!7a{o7;;V*>ZaC*4fFLc;Py#`X-WF$hXTe3##u#$ z$Z~|6;)DA(ToetZZ(g}N;C3)jo(gssibUDmoxJi<4TC2>+ps%EaFDxvnm<}N)G~NH z+Mjot7EY?V{tmjHOnbB;4dT{%{EBIkqf24x-MpxBi;GLCU#rt_o_GKCoTTayn}C*s z0`un9;yioAMN?CN}ZUFGdB$B~?!6u=9&Lr>sD@VNq$mhacyFQ+_vNhxp= zEa$!LY1Dk57sHFF_2l$9J-dfCJSJ?LUDoNBC$@v(Ff4iZQ1zL@%@x7rBvocY#+`xkov0hMN>9$6=0WVg1l8pkkfK%={a+-p0Br}10krJ@TjIQ)^#9AT z3|;~4TVwvsevJ<;DzZSKVRzb?b%k3SA74)S%u@?~5lMY6ai@x|?DpVy6z#{4LhccC!xR@HMq#wb2#-(F;l>1fJ3?uonWue1FZ8#o%_00b^AWyi;X%uRX;T?4{IyArygq{q#f}Q|E2Yg z5@Vh;*!@f0own*@vg$XwEA`4qX&)Y1rKBIt%MQxJf5^-d>Ry zMVnE9-)i4y_(F0tD=QMS(;~XTRbbo1P^hI;7mM9&RUGeJ$o4HfBgmCFa6m)j!NDBM z*YHeYd;-2P%ap=tm@E(JfzUN^eJ=pgb$1U~PEKyTYHNpnZT-Tz%lAx2gTyatlH(<06%J~$BZLutYD6oBh-#vNHyYI%KZM^t59Wb>PA~L6VV?g`|>}H(wRZ%QK zOhZwZ1Aj!S;hg5zjMj*yggv6!A?$7Z?X%%H9C&YitF>YznVh#E_e=Te{BKy%T@KPK zViLw-{|^|MVc*-WZ1KRdeqS?nf_rMZCFfKNoJx32Bgvry zu14f@UFrsWXOQnzq9keuh^;@o=Y#~Qnx_sR4uG#jAIt>6Z4R8 zy_D|*|A4Q(rw<#U{p^SSE9kk*Et(R*kG3CTF87gABk<$qL;~cVF}l@)`y72S0B%{L zLLI|@3R^eTds zYR0BaXqzFjv}acF$^84m$m)v~!|;cJxot~a*q_YkU(&~YZJ>m*JLP_J9Wt5VKA<&7 z&trQuMedW9fTI3s5th&(jL+4WJ*sH$KKBJUVN8s$#hOa{?;YTU8_XH=A z%mJ57QNw{Fi74Ix%^=j!EacMn-k=D5KohmCL7Zi0QIn-3G2oUeA8|&0dJir1 zGP8vu9XSAYhQpcBKSGs+#Y(QI9vB*^IQ}ZKD%lz7nfz_Fy;q%Q4X=)uCYt|^#cT8- zvvi4#Kt-6XjUy_RU#a>XG@7Zfaihko;ZtIl1O3XW15ESR^tKY1N#Vmi7%1%S_$IU$ z)_zHdIVf-IS%zH<|5QKz6~Ju{fd%ldk9BcNE|cG{f~Mr*N=(FdbAvSKe>1a_hhMDZ zlYLC~&0r8IQ*`p0&x@So$7j`{8^(HVk!U6Iq81M|J72|O5tNj?o%Z%7IR`kRsrj8n z6*Xn1Py;S&lkam4{ci_JEzyX*#1En?BXA|ak=iC7d>={tcL~pPcWR|m= zS*kG9g|MIeV?-AY_v%K3!l1EkPos{_hNLd~{8r&1C~piLcKh^uD7xb*NDtZ-l6G-h z`5H5IAOv2tO{Hg)XCq-porJc^lE zl0Qos!qw{3g?6o4MjQTGKejW&P6xAzQJ@{E{HK}A&F*q0jsoPSs z#_STK9P_nr{Oc)G@}Hq!@a}H*4mbMp#X^ifK)a!($OBq|JU~=@(@hHWH>H z5crE*M5cJ|yl~xL=F-I}<4+GcMSnpbC1;nCk4sE6cB4LN{~xVgcT`i`o0Seq5k)`* zqV%GO6p=uH&>~%=DIfw;gGhOxf}w^g@-aN zcd@C&!a4c4V)apyBdB(F`MXqkdu;J}pANXLFg8IxMRX8!iAhQHi@w5}>{)}PUxyRHY2%r^%#HB?G7LZ0~2>UZ2 z0G_f#``+ohYtG^5Z8*E=R@p&N& z>%t?({O8Q7mt*%Yj9sq@ zZ-!IJi;=Gakk%mLllVc%Q+xnpbG_eA(W4($H9tA)rWH?? z&o<3+b?LiwUi$P1OXPop&fItB;UMqk2lig@w$=yYf(!)X=3-hY>MqrfrvVX+y?6E} zp8zt&3w?5O_YNW%Nm&oiOI$_0$+k@mU^u>w(cKe-`fhkRAafIKSZDx(~8rr0RWdg=Q zJ!GX~=Cz&r0~hxSa|a7|DkvM^jPd2Oaq0v( z$I1g+7rN4Muw=ONsC~#a8TnG?X#jKn&Oct+Hdtcn?(l*!G5tP?)=)? z1i>bayG;;_{mhZ!%W91hW`-PeM4#t17EiUmmMK~SC_!)-rP6pp!m5WcUw+96vnI|e znW|&xM&2_qlu{NRvc|Np)=u3p7MK}1KaHqhw9txhDOGITx1W5ZihGZH+H8F`=cYxh ziE((~uqhdtE&E#Du-&RptH9CMxnB=@bc+(r6mHlZ;FMr#7qF8V5A$bURCf<$-LB!3 zXBiSI#wH_sSE$vw31Y4RouD(wRV5)`_vLs!+nv@S%(^rs;#maAPQ1Q4HW1~*MU_33 zb+N9&F!zac&kSP`0&;DeYt$fGrl9;=(N$N)umwZsg^OF_rm!A*u@5ZM(}8TWuIC{Z zllTGUpm<>eTPG)a%}my;a3KnL^AuenP9|DL7M7^Gx-RX;*&8?M3TQj=SN5if_M?9` z6XFN6hxK5$`^J?+KX*KN=>D3yt3JW$BwlZG%s@^)ZNXe?+(DhBgFFa@JMZ5jSl_LO znu#P`hFYyXAB{JUeIZ=PvXE4%7g0ZHm{A8kN8;9sTQEKLt$%M`7~jA+!6lSX=U|y| zfk;o4{uwIncSTDe4mRX3+EL1|mC4>Lts>9ue@ETNw&r5n=C}Y1`RrP$VdN)W{;g|t zDQ<>+KaXuk(g4*7qSn)UfeKDbd8hIA9bTrZW{j#^gxhFy;b;4F@%Gy{$CEVcMiXY8T5h&%fcZL#gtXO^3fc-f# zcuh@B{VG5I1QLm)47@h!)KWSB(>E6O)sLDuf(D0#@iClyxaAl@0nWz~9#}spEWqn> z2+H#$ZwfPw1ZxG@ZN3pBbR8(lb@XhiKYY zHj`dDrX*!YFA}>j$%CT{3+ijPp(Kw1s4}zxw7=; z451b8>+9>jwK@Yx2Ve|o=^+)+PYo2??S@P*MfyS+G2Z5~GcS=ntM6wG7aZ*EC9A#e z2FGsnOJ(moa9(bt@!9K5oHXT_jK7q`bF<-k62jGbh91m|>=oNAeAksDf;HkW6py_O zJTg3)l-QdC1obprhvzP<@Z%npx^}qO+FT*!^l>`6AY^=mU$n>!rX0^SBF3(cBs#E# z6WjVsE!%U|{JD{7(fw>=Bx`xA@gfG8-=^(!kb)C7Cad;OsbkyM3UGes=Ejs$Cr<-H zVi^!xB!!WYQB+nnP}N43VMI|X^_!oHa$Qh&d{6osBa{3z$j4k?r)3~RLS4k=?yh(X zf|>t7tjA=I)#K^oQMY%&$qDYuz(s!*t;7 zgvI)nsl5R42jF+5dHe;SMr0bJ64MPW6J9UFkty}BfX8S1=!gZrAz^Tm`*Pe+A4vzO z@w%}H&Dd_WVhwV(JdPM9Ag-*18Q6XV+d#dyHELy*Hr)9%0x^E(G5i_Y95fLtW#X%B zLvr$(S6Y5%)iH}Wx(B^?gajL?ss=|a5<|OoB?>}AY057iq`FNz@AedqR=O3QM1-k- zvT3MucvGu~dfV?`_RJ+6%5TtO-1hN2?PQTQiS(I|u_BifmwWcz=w5*~;Ouuhmp-C@ z0<)7EVsUoK;6r>*Ce=lEOwUq(!a>`Bti=R@zLy55( zb#7?IO)(elXc6*GWD}I#InT)vMX}(s6{HW}(?od7$D^|*{DiUymX$&4jKKzT2`;u= zI`7iPBahf60Euho*-H=UZB09;WSPv=JxGapFTn>>gPl8P7bv-G8}tJcI)Ke3An6`l zx_p5DPG#6SUJMX*DA6W(8DALWr9L`Y)3JfIiT5iHuWX6$OaooC?TSoV!z?3$cEq~E z5zX@m%-K5Rlf!Qm^b~=T;rmVkFPK}4@&1CXFrNUKuP?qShL3*pdS;cO(oJt zEO1^@(m#1tFE>eqh@X%S%UIoMyPWGmRt2p>@*eSo$QiOCNrX$fr}oUREX`c*-(^z& zWOC-n$tI8zN#I{LTGK_3I6{C&)WVUfH=xbFagp;-Sp84|o8`am9?D~r6|%_+?1%0D z&oYKC9uWB&I!*h8Ze#G6 zKh2Nq#0Ge=p>Zdac%k7YD0egjZrpac;J9i1_*kUMWo0`@Sk#i)k7kz)*prw~a8-kc z$9Ch)XP$QD=$$B@Sji9&nDrv(L9htBRXFgwn1g7A0!DOas$=mI>kKCw% z`@gno1a5!Qw$(kK*@{sc(eIn)D7{X$Rh=WMP$-qZ4DO1~(A}z{y1o=?MA1Pj>oB+b zFv3|D=3uyX>j`Tcw4;*+3Wd_Ie^X&+Rx)&0)&+=aTOqG58rzy_yjnw8tfGZ@qO_oaivhaJ7*yw0u6GR3&fGF7=b%c44 z8IeGVl)r!a-v?X(#yh0NSG(i3NZ3x=u zI{w$NyZ|_wl5+O9qv-j0=ii6nETG>f($&+0WIQvb8-M~@yv8_b3%Z}6ftAHi#bT59 zBsq`XLhN82urd@97CuB{00tv@2&^Dg4nJ3989*FRJL;16sQ>D&znzN(Of`<$eaJt- k<+uO;|3|zTiQ71!YF_`~elq=&{4t#3`z;0oi3te+001K)F023m0GRpHK7j!G`EKUgasmK=csCakl9Lb;!k2TfH8Hm` z1^^I;xzw;!QxwDWnU;WPu~Sw;W(n|HKumxaR}fU9+FGJA zXtKGTThgSlT-Io6TGCjjRc@gyh;%c&)mYXTY?4%uw8?ep9+jZqyISM@Y?w}P&l&YP z=5qXPvgLj}!FJ5UFAPt9`{_?nLMSzq2eRro-Hq?a1Bd3;pURkPOihg*m%|%XPGIe} z#;?Mgag_}?%J}-*O?Qs<@ro+(|Eb5miKUq8kB!#egolr$fL8apzK)e~$;#%698$8_H#X4KOT|QqpoM2988zi-sEqPZw9%CM$ zp+O_=>d!^Xx~jt;}eGHLAQ4qwQLZF=R!wWV$vqYrbbCKG8K=ZC9~M^l;K?VwO-G&=Q- zR_d>Jsw$OU&WqQ&6>6O(lWWd3-M2??Fj#EXK9=L_$-XOZQ+O?%?Vp`ET&|tpy;pG0 zUX9QD-nkdw7x#S1-u62?`Fy_aFC(*OhAhLfAH`2bn`QH+Q#lpimi@0#Xmr|LcZ-YN z+V5{~Y-~2HtXpsIm$ug%o$C+Uj`w_5-rwHipYL#A60P#z+QbRpTc2B1pQ$ZCc}+z0 zJ%9lVjk~b;5ab^&S}8E#SnxoItmNpZAi*$^D)hJlfjxG@g)rzU)Vw70R%$DG@Nu<( zvk`$Xpg{igW^LauFHj)03_ZUAslXvXxS$BF>FEjSu|X)I#o^=NSK=hqielj*^o@g( z5ev?KJ^8+2$Oop`zlcB9 z?UPx^=5RVTckO-*j;mJdwEGm^E?siTOg?Mie%v7KWFbv)5O8B;Irj0A1wAhR{oOiK zBdo^oS6fUic%|8cyH1Z_Bss$kIg>PVf`n9EKi;^2LKb!NNf0kHaPgR;A;EZf;Defr z)HAyeFa^zySiaI+)Xr~8(+?F)TTm_G5Fl~ZP@xwbVP+f>1V+u z(ot&OPUX4|!p|B-(eO*aZ`opa*8SN$<{Lg~@_1odMcdaiykJd_fxp5Zpmin#YrrT^ zIjX|p3z-A@i_&|nJ3$g;u!mHECeb!#^7iBB0jMLA3HP|+VyCc8@E!#w#tAhH**&IQ z^BzT`{yM-K4|DFe#SV&>ALU7(MuagiixqKmF1rrrz#>*kADZKw1e>Nx)!NX+cA|Oq zuaBb)Iyc5_Kv>oYom@QE@b|=jM+vq(_UHiH1@8UoiZV#i?%4oum|?gOy^B z?3JLX7t0jxCC@?}fN|*s&C9EaxiXk;|ynjvj(#+(U0X zVoUsNJ#w!M{=)`eG@TerlzXK%m);4N@|@5sLB(pN$^paO9N-}iRS6zcQezM zO>Cw8(`t+#(^oUmF7=Pi)Jr>0%-@D-7m$^&UCvH6gKJsUSUu$G|_9ETrY(;igY0eT6cqwl2mw~^KzfBvR{Hi$sxHA6=rec6Z zd&(}*UBlRmpjU6&4?Vj~WFLOR18LwAbs1jn+EpU0W6YqzC#`1bb$qsjd#1Ui2qz6t zx5*{c9!LgsWpYWp;pXHhlr4;pudlBU4=*oI$1Zp$JOgva_iU5I5&O)&v_In~=Z=05 zsN`$Py;)<{C|}qv{>4lFo8CHJq&@d0u|!gq3t~h1kT|8Qq^0mSH=*8eJdxS0H@jDC zd^oP!@%OALg2htT*Rg+SQj@Z!tHJs`MjP*9tHBlT^hFsQ*4zE%qz+H{OUp!xZm6;YuR?MPmNuFHP3=>4yYcJjpK@YdLg$LQST97~{ zF%gpyMu%tzb-@y?!kPRqZ@UE>L`7!Ye*Ud4dhte|L>6iW3WZghq0PF&uLAmk$MZms z>pyzmCNkT$Z%JOOjapK>!VMI>Y80v<;|?;Sj<6IDx4ngoi9}7kpU`je2lZ+W(IRae zVBR4D7*wiM3lZ#9SxF8eS%SEQ5%MR6jL>faV8t4^6$%u4N*Zk{6@?1b;mUKP0$|<& z0z_ot*{_Y|Y9d;VD%FMOkMh63KFx$0MXSSS<5bC(%}>BAinS`{XIE#zyps%pzd%)} zt*s1`_Tql(qHJ>|H77M0X--31Yg&CHTLWWSH*33}G6?{H+l}+5X>IJJkMCw}W#h={ z#zXiY3(lYRzhOE;{QsCZS@IC7$;jag**X~Gv(PfoG7|Da;^X6UI~bX8DhP}IxBE|z zhtSN)$&Qnb&eheG)|H9Y*1?pHfrEpCj-HW@k&))dg2vI^#!26e#>SE8zk>X~afFQ> z4IRwwoXl-)@c+ftH?Vbf;vppbm*{^!|FuqIH}n6^WaIeX+xpoc-M<++23mT$|B3zM z%KdMYQ_kGY*h)><+}ha2@h1l_GXn$nf9(H1Gyj|M|9GnZuO}lDHLLjk)T84Y&We`fYa1*LG_K<`G>-Gl#i2)3L@>p<4MPKSm8mnF}bPvVI!5m#kwpcRtmvsU}igxdPAgvO3+{KOc zP<^pQZAO*1{Hymg$ryN64B>?756iuF!<8`<{L7|7`Q7$Pz%B*_uUK>$aRIp!RJHIc>~RQ~FLMgP1qbRb%HW@1|=rRuC(4~G7~ zw4fng<5?0%om_gC=I%$oKFwrXWbocE7Y+9d_A!s6SwvpexewUYeP!CrGq1N8u|w%^ zb29#fu5@izk3-pLG1sRb#^dmue6IQEV?=iJXQWJ9VORExu$?Avqx~g;wGj1qHpcVR z)=%coH-BE9j%to0%)mt6Hub2Qw(p9>TX_g=&)oCW>{vA?F--DppVpkf%U0_)N+@H? z^ek5L{ykf=B);7ER3}Q;IXsqqx6L!hz5;c;q}1B|kl#X4z59qEBV1d_e;+!{Gn!H7 zs`a^Tl4c3Xo%^2gs2C7ZgK|HQ8B(WQ(OvR2Jj@$f*mQE}z_GV=~uPwOVl*GBhbwzUg`$7?9ZTA`x>M`%UmHp2HYXV`~b+1(}lK4KDm z-(1`DM{FTOPVQu9NJKG{a7809k&@y`t!1#mj|K6obuN#au2TWTh7gFBE^KYhjzKDO zcuK#+tL+aMATO&eT?O8?YzQ>l&||$jzPb!>mx$*((=>M_++ng=XB95xI!_skFQ(82 zhEZeG--fGnh^_D-maf#Lp1(}$(OZ*3!yAcaSC@-(?JXk16#6>qTK9U;E~bwjEg2>U zyT)zDIcrVx6J7*!mM41it?(C9XIM7|a%FW}j26)m)b@R1AeXg(mg` z+zi=MQgrnA!|XQ|VFt1RSLsF?UF09zpY5-p$$MvqRBLO7dp9?a{#g7v>HvUehq25p zBfEkY-}*Y@<5>bg(qYvDg?adcO*l(9Lsy1%52NQ4$e- zN+aD;WrKzzTa9t*el*p?UAemnT*eJ=79nPx;K=_5if>#78TpO7Wp$6P?R%v~)21xs7zX6r|vuts#iq~1_&c8$H-v^wnXF83kc zD0!%-sJff(zFYMYZQcpb@C?uh2=uE{R_MSGVFn=Q!+FqY*yeH3n9N0_5jXuMbb?%g z&7=ljW_S&PG!w7fc0t!=Rn4MwJT$e5TvK(*LRNLgrcWbNyavv~wh zV!#t~-yGLc6KW|VGjFSIcP7O$?R)Xd8fMyVTaxKPMcv0W_x1*LZ*k35u9qJ(A7Vob zq}nlN$s(ydI9IN54&Q})Jh7E2Ot(y_CkrBA=XwR%#dNy!yrEk%BwJ^ zRyY@MfsQ;;#zZh_eHZX3-xp>(69lMxj;@X!M9lheCU9>y;Pe-h5iGIpx9s<%X%EfV zcvLHDfM#z5b}-)aOJ~P64kV`+$hU}t6f~bQa)WM}bAS24L?Rq3?4aKW-C3Ov;z}rM znEeBJGr82W0hn3JVngKW(|Q38Pf^~Cn<9P8D4Tc8CI*PwL|0<)b?_F!WIlE9hy7X} zAui_6iLC@u?HLoot?ZVdtef9)GQ$sZe?#0K1R>gZlyG;5GaF%~U)gNe<>zk>3DQyk zbwP?vb-eDP8VLxCLfKB-tgAEYN9aV(9wcz#mY5Q%K^kM5hO&-*W=m8pKBlLXMfR?4 z6(U)wsmDSImt^HRm|I8WU5@a!-E|0SJ>*eEv}z@WAaN4 zC2yeV-w)D{*e+cQ)fuS7E-TedBJGe8CYcdz_OkSvN(J?ti+Qw3;_Bo;*`j$>Qg-TI zdpyxbMmQq|xqD40*qlq~U^&$Sk7M0V7qSo;L!|VuFKd!bB}ri{IF1@n{#b$0?Z>I#riUeo&tTB_f=n+kvCp9aDmc0c5VE6axE`6 zAg$$Vyt^oe9|`iWsa#&&8?%fqUY`X<4dC_BSQB3AN2LM3qcAy}%cQIUEyBlD*7c03 zC&~gxkn&9ir(>F_vsVO1k^M7c{45=rZ%v|O(!VP#r^$BAg%pE_JuYyH2nZpL87FOy zSDeRY7)z$xJbX? z0%I`7FMo}};@!N)Yj3!_J2*kxMJ(k~~0 zehYb-%_0MGfnY=XaN)W&FW#IQTpa_vfGV*rqyYEz3~Q%Q18R8axYqAYaMN+!&0m(e&e9Hnn^{mqBtS zC#?Ir7l@7;I=ZxDnOrApsn`W|J|{HTAXo12U@soR2AvH5?2PyvEstxASK{HO@2avy?cSUhw+Xc&P=dTBd6LnQE(svM#~4A@ z+<#1_+%3OZ6%|&!&`n~JDGl?Go+H5Z4Lp=Ztk$*~dvc+3Ts8vE_wsX}#ftwLpOiL3 ze-kI(%Fr&)D zdwKP1EOL4=jg`jcMyOtP8}6eLGVDSe%1F>lPPr+88wv`>5>~k0kwmg1$ehlPx<+Ln zLrxqZGoaDRnLPh`KJ)2OpNk=!Nc@AG zi5i(p$%HeT2ysn~PA_+=uETyyvHt)mEd=@5IoI2vn`;?m75G0ydx?-&F=ewo&=`R1 zj~nDCNbCC9K+gk#rlI@;PIp{{`FD>*|CqNJ_RD8d03ic{L$H5on{vfM8P#XL7fOYS zAQiNl*>4Cm*)Wc7jbItacAD5LXC)J+Al?IPF1ek$`LpQ104&Pbu-9cX|3-MxCD?I` zSu*IVH?p>oJ`36oYS68>>3X|Z%f)kd*&~M|#-(TLH$xAK9yCQQ){>N}DCUZ~!40yX#m(tfPG@=_%#uF5t@6BokI6_xob;a3jYDkE zFP;dd$;H8boGC;&V}${+iu&qnV=^-{XvKOotGbz2YZ2)=O+e}r9#R`L9oC1?3la~A zhCwt49gW!ua@fT6viewuy7JOD0io>tSoY1K9%hV4FS~t)lUqG^-Gtsr3gb#ju9ZmW z0CVShy5asyZ0g04c>t}&=)qm_@>It`eX2L(vY#Cet{hPvKELdJ zbLK+HL;XQ{8>&NYZ~KqIAR6Ejpm1q=G%rv6!A`+C&TPHU_My0@($R#+WsP!JNZ-FK z@~pm7#dv83jxjBP-f6Shh(}&e={6BTF`PczkD?_Hg9Sv+LP-g~*VmclEqUH;o zzQ=%uROjG<_E7_s)|LF;KL&o0M4;E@b{<^gBHocS7Z8xyljGX}Ngj4JW&?day^lB- z1b5#zM2YC$*4V>x{9f4E=;x?=p1zU8*W3*D-o~M2Gg#)Zn+qh`=?nc^AjAc0n0toB zV{=pr$Y0If^!=V#%s(3>D=u?3%``{#^OF$ixowz2ZNtjya5`DPu+VKW;r>OB5x)V0 z8@Z(8cA7cfUlQZv(aL&$9*D)31@Sp==9}51qWiTtSi77XzVX&u2ltiDWpQ@0%rl5R9t*FbcF;4(@Usv1GM8ha+cO8AJfA{ix}@arRxCR z5SThm)7x0*)~@fD^|ww=NYnL^Hkfi5!Lozwr~9$|b{+-O`-r#Cb{K3}VX*vkxwE7O=S8^hk7Q6ehdsF6-krX%AwK zBz*o2Y^CnKcU%kL9D_dk+Iq;Nr%s6%R=1hP1}UcOGcu8K8%m#hBa^9e-2 z+(yPtlaHv-6HPqpK9vtR%;#MQIg?-wbJ{VoO1&5k-dD~2gZ(C-oLddt-s4?{2>FG; z=f0fkm}%^m9+j>_fiP-8Dy`b>Oik^>L^GVP^`Ql-;<>-<30z^J($Rq(VZEli)c}Um zK|eiOfBPrxn+%xAX)hV)^%FAeJd%MuKHg={Y|CII-z?Z^Zv*;c1|_3?dZB#bj#PwU5}it7>Ec2 zetGDA^JnYU0+k_;IJJEMIY_m)cmL5sWgrZ~COGVX?EHGOGb&9zUPyvq7pcMg-9hyD z=4@2@yQ!EVVTS%qs>);e@vZsp`?D4v$UWZe; zmzFkuG2ksKo!2TaRkZ5iF6ggi#|Qox7SMB8f@IL%aP*xj)uIk3oiml0>a;FUGt@An z%QY~TJTR}^G}Q0pnu)D8whDA}hB)S*Ro~l0XjcW$ia1gkfl`F|#&|m}ei5@VH zN-zXQR(%XWNm^PvV$RkxDaye%#)=C8Gy>sKHohlxZ&=lzs{-{CG75{aM-l1U_D7*?0={*2z zzg%e*IM&(^uTCp;g(U@P1S3G~Xh>zuOkrdr+ELKqpeei!1+(BXB6md zw}wqXY8wnIlffc-F2e!10UgoptkEly#~XUxh63p+Ss~OZU63;gd{`@H!k9;al^M|~ zP-75c8E#D2^_CvT$crJR}n*75=eZD3O0?( zsHP8t(TGRWc+;ED_)03HWK3G+J}29?hhBypXkOq{7NlIaVU$T!+C+*^FHeH;A=&91 z%C3*2c0LAiKuT;?0aCG)v1{5rkLT?IMGuiKK_#p^|Afk@314h$sPN2&<&@mjaS z);;2T&h&c1CByvMkX>A#1-bn(g2St8tS zyJD}WV{*cWHt1phnEV)#n}Lc7G+(O%0*!WeHXB9(3_6xK&|27jH?+Sq-ANw01r5rM!Yb}|5f*H4u$Gt=mF=yrHI*oO0ep4Gt2tjxiskF$=J zVWp{1l_N~EwlL`VExwd9`TFT=iTZ7SCs!Ou$&8x)q5XHg`wQ9DTn|}C(zRK~z*-L= zGpN2&4yp;OS&V?K>vavTgJ%cIjy7SON7;s(4+xmrp$#>QSJP6l0dy2akJ>D8Aqkm` z020MNEH7~&TrfmG4%>!sfZKoAgq6ugl4a%>*2iH$Gy@asNWdjvK^#M7TjNT_-Nl0> zLTe|y9k&<~oQb`6>urgghd+}c@UGJk9Q6|+KZuVhA?zs)jSOD(*hn7|dCed~Eg%T+ z%A{1khZylOks6I9^7EhK!M}$6TbE4`T9_9WFqmvduq-bKDKbi+RAaWuOL|@B_h!5&hjA zJmSTKV20j3qw|MXm62q+5f+7t)r4#)1*r8(S35mzGa-p&|B4@0L4HofRu-91e+zR) zooYiMAaCwhb)C?XyVDih zFw8&yxGXTBmT0j=r7j#-BD?x(N%1C8;(FTprm>da!5Zd>*;N?Kq6m2q9{~^>xVV>S zAVH`iKi!QX2=d=q?DklhvNHdokOL8d=*75bB^Am4hn=&-BI>bD*&braD^dKJLH30#^E2bC~uLs|A z>RnCsY<`c{9s9bKPa*2ta(4WI^f$MvEHAIIZcHHlXga=t6p{1nXK@`Y*Az|xANF`^ z547#b_!`Otr(=M<0dKx5T-C*^?A1LqvFQ3@e^*EZZpP9nOG!B@)Wzdg+nQJ()g}Rz za7e%Fqrv-DIxHnb?8Qn30k+Hkrm;#$>szP$%4qY>r&U1$Avw#JZ@vgZeJ5G#+q$q3 zvfr82;cG5vE*sK^;ZfE(d@Qc;)j1_kU2VP#432D}*8N(Y8^ltvp`_<9Kr~A@3Mz_Q z^dUa(jF#;bA`Aa5z=-nE%F2WHc!^Y6hki>ZixV`4#L+IR)sJyxiJ{Jca)B0#eY2^T zKx6Cr6|V3>BeNXXcBf#XGUfiX?och|96zt+&daplp$%@+v>n&*T=wVb3fe>7GgTk_V;n%fN6ZuxQKAK5DaW&^Ks*{m8^fjMK~fPv47Q>6^nu>jR$wxBlyYO zS)XdIlH|Y_uL`YdA6XtJq_Cry)|?mk5ydW&vh~UZ%MXd0tpU8<1VpqR1X~Qzk~ff} z+czW1MSNpw90v^G{=NvjH-AL*LSENtBHe3cEt-&7@H`d|zo}w#-91OUzbyTE$ZWD( zi!!6uiTipy4w>m){KE)2YrEy8l;wo~nPmC$@>vdY_RoSf>F^&6jG4!24>ayAQYBPJzXO1_z-5U>=7$`USZ0&@#K(r6% zR5fF~nt&9ahZFTC-dFC}Y+4iK=CC*?iZ_F7E^BM3n+jcTkHD&iqTL^Njp#zRXD#={ zQhyt`;0{z@Y1}v-+Zu~ndC}D@6+7e03}uZ2+-PEe7nQz-sOWFF715?N>h-+F@LT6I z=XE8vvu(wtWz?IAjcp`-(|9>bvrp%aHd8%|i#nRE);7%3hP%41@&kte3dVXCUMU(9 zKdNM4V4-^g67sN+BgPmVrPLj6uaJ~S_yB$9>RIR~XCOo8q=4p+Vj-AybUK-NkVO7W zMjJe!b@QwOcpZ5-_N){c(@m$N5KV7Rl^H-kuAYiT5h2bU!TEa#%26nnGMm00Id&=x z`459IU&zyJ!CK3+kX~LHL+wWegQ@(!8#dwZazvX_Z?+4nKiiAxcLViyNk#Upi%PCL z8K1J=w9ZOq<>gSw$?oz-b3(6ZgQgs+r7>r19?}OI*GgiHTu_f5Do1poPNQ98fmAS; zzgqfS+xvgXrM8@@1v9j&Y-DjG3t2a-~_)l zp5Spt|HfM5vXraPP^i(=hulxLg4TzdmOSsdvpX9;tg_uTfnW$!VEU?;3N%c5wSW?&v$DPGkdnrxcU zu1^BOaiOkxsUSI6Wak1&>{*$ThlohIv{JJEur~&3zgSY!O+nfUz#e=>X^X+mzCXPo zkLR&2_iUJ zt(}>I!u;$Zan6!IDYE-sNs@pXDvcAmrlCz8;t~Y#gs{JP zyhD4Mg^V&SWCH&O?q$!db7fXbbf0YIi88Nlh>8^&ZA zTjP6(gpB^R7m1`Vr(sWLgnq(Df7gquk0-DTM<#>Qypu`h25D^MdlHb~*X^v9^8Dt| zVH2F!yDvgQYp_}`QD7g7OrRfs@=}&qOkP1~wKR|i{4V>cRnDoCFCpz;Sgm)a_ovEw z){1OO>)V)=f-dgcjYxtp>E=e8A(+dx=G%eoF0BUFEibIqH8pm3z)J%E=p^^`L z`1L;HW>MgBqaStRti{E1>O9gB2q`gz2e(iZ7Tap>zM(vKv-pZ;h_;;A?)FscT_7 z{@$-RQFeUontGyQd_HzQi;U{K0Ba_3t3O$TW@}8lz2?KBnbB*gv_=Tji0qN>Dl}Be zY`LV;KXA;AJyCSFvVQAA3{t>70f!PEif5%JMvnfh_PQ#69etWYx%baJd%q|rdmIG4 zuHmhH#3K{!Vt|~=>D|v>n1YzcW7R9jZyiI$>MI2Kf##a&NX0v z*uG{p#eF-Q8VM~#wmzo{NHViG1JD&-uW%8R)O)%Tvi#B)=_L~7s4N5!(Uf3Seji=i ze|sr%u4Pg8Iw@Ne0g_^}QMDNmEZ$QT!Hz-b_t z#cr}h&Z4Mh(RZ22GfIfPE@p4M{0TKoWIGtIFhy+WE zhCxRK0>AS~upcyFIJO$61@7hm^onlC!{uGVI>cLXI*AzVQ$kBcRqgkhmPa526%qv} zD8ZoU-)kawVpl>MGV3m~YmF`sjjmfJO#ty_VFyC()7+kqdLwPe1=5O}HJ#a0-e-{>JUevDTtFxv>(Mis;LVbk z)AYI+nR^@rVY8ogb|XURv7+U1Ax66LqBj>F!S$_!(D7qB=3P^U>?AopNAhsXU>-m# zW0^sT{GFtfgj%KH0YKTOx`E)R>8v#-P$>RN&kKvCbO~5&`u#pUkJ2-KO9a_h9Fl4WmZ_(qUb@#}5#q04SjR78|AvZZFT-ty$Y zxjVZSf3TI;`@PR;0x_5Ke9MlckIkKr=`j z`7{K2AwO~qA>IuVk%1mQn~7Kytk=qdV(SMuA=rm!rLIqE~^8tIQ zEL`JW|SXeZK#(wm73T?TJJZiT9^{Ut&*>?(9W5bmOZwy}*Df$L@et*NiY;T*_h3e1!4Q5fg|FNfiQy%u z2mD(SruG>=NSBMA#LCx0Vg^}^E3Zm&G$M@-s9GZ!h~jOFX~@=lDZhC22E92KR(tDh z46c8>7hGQ7v>!Yxh%18jjZO+PrE<#MIC`6FdRS7<+i~*c6iVTDXHt9@YI%8tEcC>r ziT!?Z4+}R5FT3IQ(sD}=)9s76wat}33`cKa#I)7NNh!K-JV^K$-PKlu8CT5kbao=N zCrs&c5yKW%18he^aDd?(#XX&2ZX~cY^dTA(@x;n@lS-)2K$m=_^$G-UYv8Ku@|G-3 zm}VWX?IzV=ZIo=3lCdgjP^^jJ&1PK_0LA~_~Je4Eoj z?t6Rxq%vmDqqu~XhTDD!eVVjM#iC5bU^V33s#a#No^lzoy%X1gHK7MhJ?u=E9%tXI>g{|NfWfj+QA*iR{yn(@l zN~T5y*g7Z_j7Q*xqEtDNSs+V$Z%@y<-qlqVxEKbs39&2iWI$y$4-F|Wb$^luO^%k- z8I%M*qNg~m1Sdn@qF`5R$aBu?#a@_s#Zj;y{;$2YTjoEik-b(aR83bltqAc7z+^5> zJl>*N#2POJ)?m2GU8pqFA{B?ZeqqMyxS};J?tS*p^iYeL%DgX5LKUloJ8*BH2s>;C z7n&XNUr+6$DxH9BE#;uBNDq7m0{FqimI{%BdnI!46>IeO(2QX>y(Zh_UZ}3d&ISnW zV8KlK7~X~r?J*UlJ-;RbJ`t^%S7Cu-crYR8X@p#ry&WI!1iXoS;tr1Y`527GjJjFd zmoHB`7whv1oy_K$SLyZq~Vd|`F@ zL(5YeY{qTx8u>3I#=P3BY;FFP+~ZPdsV1+=as(+|cZh+RY@39S>*}=6OqZQ3{_k5Y z$so0(SMg*n}C2TV&^XedakA9Rlxr^dtmq<&K`GWq%q=$vk&|y5GW}#7oh;+ z0rt{lClD~q$WTf8PaxbXaAw9T zLXo`Tf2#CYa|;dm2!1$zKaa-$A0+7BICZhh6A%%YdgTNon2gh}>ylJ&0dFfVuG?9e zb`BF5`|3v&Osdyx8Iw6QmCdRNL_K9PQfr+OsL3PSaelvAND?a`CJ2+d5Vx+g9 z2w=HKY{XGoy}5sj#)|H#l8*9tzSVr4sbXqhvYi@A8Jdpu@F==Uu>rQg7U|&t%Dyqn zD{@=TSX_tVux|Z6uP7gbgi@xUsH=tsibf^oms}LFJ-YZPhy8#ubsbK1dBB1JBeeBT zR}B@Y4#G6dYK%B9b>K2_QgblHNP?W2t8SJtg(~^@Q5Qz=n{sEIJ`4wN%?FdjhRx`5 zC+eyl^^J7plirU(&Mj9?_FOqn^s(F<=<{#w?8tSy>gg7jsE^{~8Jl`3-9N4$%|xB6 zijn+iNih9?yD07L2CN2EZdIpA;0{MkI0# zuPD@6&X;Vik_4$DWwC#!53g&-YdE-AzfE6R@zBv*elal(0ENt1=Xzztk5dRs%Ggar zq2)+&Ht$}q|8{_7wfsC!bt>PDrD@~p>&JEXwm({`fkRHwVqw(6h!1Bvnw0v5|D+kg zR=K7GYXC?gyZy)Qnl4j+>Xhk?ZJy@6<(P9uH=}M>H~p}75BoOr*i^(^+&*N^FxTtN z*yh)_9Q{QGCu8{p(2u=Fy1~!HthUaz75ozc? z^>O~gO0#VD1oiBGF}>BbIdiAUhs~%q;RZ8i3HyF$%e! zzs=|(C9Jry-rhs#(PWQiDLX)dt(SD1*Vim>Zly-5X0UUQ)z>rK1k7Gq&qaXDwNKKZ zLGgmf?_3PsMmi*13G(+6Y7g%8Dz!iO29YJcrfSvz5ipa2LxB>zJ$55b zp}wxSg5p>&Lgp(_Yp4+7ql{P2Q&Ona8W?+T3ufZF9Lz)9RxPO4yKKxC`;_n1{+Lj1 zJ?@=d8r|4yU>K@@i6CKWJJk<}VDDg6+{b zC1B*NHEHL{Z!&sz2?Wq8XQ+O(v=|yYtlDH0Zv*K%~xcK+)PnV>G}Sbati+$~jwi#$O_GqAjIY$>`FSo&qn)Te zahcQ~&9eXbNJ3roH*G=rd@Cmxd!nSgYr|)aU=9W2+VM&n?Rz7mMK>o`8kJl9dux)i z>ScMogG(`F();RgSx2Xt-g+tRfZeso+vu=fogs@spW;51Mw8W*Jcn`(T!vrG$l)wu zS*6PJ`%_uU#9o>t-BgCU20Q$@-$|e0mO=j(;S<;WQ6%&&*?I+`e99}+)pV@ZXldwp z&V+;v&ieeEqk$7DzQMx4D9m;?NXGZ_(z%94oIs5}c%98iWhD?G>1@LH*A=zZ#73&F zr}e5MlnF^J24v z792dmuV&C8GP2=BSU))lUU_@7ev_$eivFYS4wK1os6AlNm(l*jDZv3vnHpq3RR1YA z<^x{C+wG&+UzWkYbz7Yu_gL1|LXF4vuXn~yyCr8SHpHefBtz7dpMKf!EK&Yq=+|-4 z#WP@F!V6pa3m%>21CB6r{AI--GVggmS}O9lF+!dLJH@X(2O?P+_sq1 z11Jn8A|hfzBD8c@<%wsmHa8mych7v~ay$6&EpIx5hCA#RX5wF?#W~YlAD7dP19-H@ z+^x3E2KmHRhf}Skp_3vEN5#ZeYf&9G&r4?Em(x5XE}{{Pe0HwI_c zL|Z3MY}=Ta6Wg|JJDJ$FlZow2YC)wxa11mg@ASBP`(5w$N@ z?)Kvzii4-Mx5&fgj>e_hF3o;&$R9<>7(kec@ zksIgpZ`F)!9ogT})Xl$UbL(gZ)cVN*#U+c#6DDkTHR>ELAjjg9!pvQ)wU=JRd#*D! z(_#Tc$T>oScu6-eQ6b*WbKAOkLSE0KzU`I?8kLB#42lr@u=4Nb9D z05CQ9B2^k3x*7cPK;JApRQNIt27L{2RccqG{{n;iQuc(|XGSh)E1a=(jteywzDTHV>!E6q3;V@I4 zSBSJi#vtEz@MHD{?dpMxQMHJ{;igOy;$V2W8>U1v>x%u-2}P71lEFF0yqzR53&#AA z6?RVs@+pGD+^oR~-@mJ-mYaJR3f03|#?`q=%L7=0WjeK8MrvhI@GvhJ!sAM9SWzO& z#Hcofx4Dsmluxs=B^lRUjcjFY1BL@Vkoux;r=%QFtcgzCmpivc;3DR4XKEP`7&k!~ zL#E|a{4F*bh^wIvaP@A=u#}1ozK^970I)8A8S^PE?DrP4fI7FEoc?UV)sAHj^pkqT zA*9|zsF?1#2E#`MN6l9#iZo#y#Pzu=nr{!ZlAKfw5 z09mpAQ{x_NCzPBpXt?#P*(mtruiYhl-O}BM(rM0RWL`JcTc^FGT@ARRksprgW;Nc7 zMIc31%>9gZ(U0=^HniKY#r>A`*obzUL7O-s<}wn3sQfE$_`E^u8X{Wf>RoBMpHpMU zEq6RN9%=|N%Md6AAV6|hTiTc|U_e{%XI@@T-ZVX*==U+4XLvD5zWrZ+wPZN<;_T^G zJDyUOs#(qUR&YE%N!vZcCp@fWm*(iyiEYl;s?}n;5rf4vLUMFE~uo|B3PqWY-|OS?G`kwQ7lbA1QfvO`Ir z9@p(v+#t?;p;RssTeOW8FXK=2c)@sT&7$J)y(A@!dHGvODh`_cR1kYfs4a-9(dhVg z7P>ArF??|-gJGjS8GOMG=$qr-CDpom=4IzId9+twNzY0onG5>aEzIZi>P3utUT{ko zCrEi@`QsYIyY6Qov(>nISpo-$3z=25T@J$04NiVNO=U+w}B5eA4`AEA+(w;PI`l{;Zxv0}H`((VXLel98OJb#TlKWdI zfsqJ2fLjGdr#c-zu7z4vCm&e9)~IG?C1Vpt0+JQ#YiLsA=$Bd%7U=K&b}|SuvU-n8 z^HXVWLh-%LG;GYWRrq_{2x0R*`qCEzjx*Md%)7r7$|AH$zkj2b14->vSyfYy^Zzpr zNxzEiiq!jjY3BTU1+`#e=S6FJQsIa*=)L{a<(Om-wg4*%XuPX(EglJ)$mdi&IYAq?)UF`mGc3GEZIej<{bmUzWMq2P5Hk3+8T!- zD5z|1CoRn2o{Ao+)7|QMt4o1p5$lor7s$G$AXhcHKjZ5fWv*D2sIOp&q>H;|Tp#4o zjx8z8CzX{T=(-R&h}Ix`#^yp?SWe(dv;lO{6 z+-jPiYb2aHU+#?fFhs=K%{;hR{ygC%m#JNND_GCda5~r%q9m%_RNIhzKTdpfMMRsX z(VJR}25qXVWlVR3NDJa)z)w-NH&RsdH{o?G%6A58W_)iyW*>bi9BfR?a-qY}(2d${ zK5Iid&$HTj5zT+1V2m(yMn4z2uCI_NX%D*?X%+CD-jzkF_BcFswfDQc) zN4n1T8k74E4&o;S&V4+4c7E+Wa(m+0-2l&|7 zSVti%CubY&9-l;uGhy}dj<5|7IJ6554gr&=be4TZz~iPEe?)4!IbW19c~n?fxVLAF zrIc@r9#}($Oh`zGfdMh{#i?>Q#;B7ss@xF`?J`xgi8;-!kjL-4I}pn6`#Lx@)c;H- zg%msmzU|spuNOr{o)_&{x{X0-A-9O3Wx+k4q+4u=Yna;+_y0EzD`@HEsb3QOIfPdDsx)Y$WNqDZW7zoud z`-VJM&b=j~jd^@r`Xr9BF|`G+F(1RQ90gh%%ZV~g3an3t$fRJ%9D#YeW>g;q6$uIn zHItwyam#PABdie!EtteL1rU@QD<~ZekHMQL+EA@(5D@0^e_8hMBGb4aK>uN}(a=Uo6`PlD1m4*8anM=gM#UXKs@-?H*L_#@# zhB8ceIv&?qRD#k$J33~pRT};`5BHm&5+mUtYgaHVrJBC@x&9$$>&&(m{A(pV>k&hu)7CL$H_cb5=%fkU7Xpxqvd{`c+$G2P`84oyySj3WF$_^+QgjlEwGq52ANML*p2KA;lJbMzuTMV% zqd!elj6wy${m?Ynz6eS2S>O3}SJx_6QH@;jIsbm%>Z{WR?Jo$zUC-j5*EfonDvp{V z`a~BwO`7_)v~zk$ue~6LkA)WQLfcfs;$T#_`}3>RRSC>3b2QP3{|EzJA_qqHc{2bve^Ex>h+#o_KI?DT|kU*|pYq%x{~7I(EK zw*r8Q&q_qE7CbUB@HLBxVawz9S`BFW5TDGzEE7Kh%d>5XQ^>+zI!Xkj*Dg0Nq4s$i{Qckx-?$LJpEgwK9m;TuN!9<>nbDV@O+L6c@p#)Qfod zRv`EFSnF@M*@3aKl&ruHGtno4O)R{Wm4Hr5;zYW2bDygQ{c(JMDDvTyrsDmIM1T+s zQAnRc*}~8Em=WCPdmQAjWx!mjaGtn?h!GWKICY>t_PgxKJ^TmM22RP^<2;Vc16BG2 zH1F%4$og}hQJG_)`&2ezbtA!63sRDRPs46m`7fkz&sdzR4P8K;uOBuVTa`cHkM6Ep ze7)-lyK>#7*Mg0#9b+K2eI88^JOq0Ce>o@Z{1k4{R0Jz3JnCCspWf~2i~Pj``pUnTIq%l<|ZzNK%{Zqo?S!3n)9`}Hzx)~>Q{6;gFSd%t#qmIKi<1Tqf zeKAL+6ycn*c8n|37rP;6>sCy^#C+qT{eF%xGk3bgn?TAdJjWlDqb@T(WP(F=^qAN!CH>G!O6GRPlH9|q zuHvL2@^cJvPcx1{Ey=jq!tcx(>WS7ehD$#Ag2 zMMv1eZ_0j;pYfT0XEuvoo7AAaqB#6tc9#ewYU`myBAg$O8yJ_MrgFaqyV~c2Z#27F zXg?%82PP#8o?(;m1t$#Xw#n~#BPOn2;+1?DU-XE203Yb}L zhaC7BismUHm247Jx%B1ro>w}@8Z{nID#+p3x&umpdyzG+cnU(wBoC!2oMMFg1}z_v zHxirMQ`qRLl2;#$I~i;asy>y&tIPAtn!DSethgoGzIi=Om|(2hFcnte z7&wi^Z0d#pB=&;7*M@Cp+Bgk z(q=UC?` z_iz+2iRa0#=2>mJz+arVLB#vtY5%43Tce2^tUdB*bWqVdMP=QZ1c*v` zcGkY`jQI7;+T4u>zjd*qOXDf|5PffQ>V?TtoX7JaDa((q`u2sy?tvv;w+F zNjaE{=uk?8Q?j&nBwP;h$cpG1?G4|qz6{Iv?okhP^p0mBVc#VFgNE%}KE-uqt$d#$ zI@C0Xqkqc!{4@H(*v26f3~iu@7NFa2SC%9U^kStb`Xk)sZFiKPabY%3G^aHY1pd@E# zF>-wwT+roBoAOoc++&4xrStrcaTTkixt&Z zQC`K|l_`gLOj`WA-`~_6q|HkR1!}0)HWZ4ddduK~Rdckfk09I;lu(|E%HvWqYi!XU zYyyQ$y{oo?T0(fzEbNd_pVG<~2AaH`R=!ZO+326Qxz1^mWRNWk{)r9OCnL(i@dR8x zrqdQ!5)&C~STZ5-aq$m=sU#u`-0YL5LB5>D2$8s$r&cZSO69I~Hi+m<^1KvAFZca% z+vebYVp$b8UGg=1+}uV^fj-}kLqJ2n&PJsUWb@X#2u>yB^$iK5;6){z8>EX$!#q~3 z@pOEe3G0U0IevF&X%>jMpP(Ja#u1I3Z$yHoEJA=OLsV7OlYR*m-`tyWX$F z1S4%m>VkZL_3(xca*PHmExqqd>uy4LH100Gwcf^IWbCOt%sF08^++f~lM~;zs!UVh zYV%rcffd#LHH-c5zJ~Rxapma*!MLKj=>95W2N5g2eOj3|U9t8tHNGwb&@z)#lbct6 zw~N}#FCIL;hWUJ|K3pG1p+t$a^^spnM?RA*q3cN4rPk34{tN?v1P@ zt`i!xxH+Jlje`2TuD)It;9J?w>AyUL+bf7vnaP0IKH3(rsH-{YoD84+a1gf)w67gO z5hFmuO7|y!9&1b3YMHBYo)nT|-bDA4zZAV7U*A~F@#L}jN3wW@+3KLj32nsa0$h!B+*M}3xp4%>VsDlBA+<15I7a#uT6hvt4upHY( z{}&!HDMaOArhW}{$0MQJ^>?8pp`Lzi`Xtz~!!si!$;a`q%}!mq6%DeL2_5m;FBWh~ z_n_V&hvqO_xQFp&$khxfVzt96D{VRt{*+I-;JgjbSI;V7?~>MBsYDG zgW*LX1pw4M#){B>qca`dthhyYddp9y8I(G3hPzf4&JhwIxlyS3cv?`)2NFL1g2(48PFjsBZ+%UQGyuT?K!pVM26phx{Yyk?-O3EgW) z`UC>V$Ipe_@I20`i!7Z#MI_|K#>Iuz>-hBx#?M=V^yX<842hSR9c=G-Ixl<+8l@1N zH1^eosUWeknOmW&oa}})`q!+f)AIxR5w7CH?(gtR4B+1nJjYx_ghkoYs@)3X4=Q0? z|8P1EUJNt#QwReSG)ihRGi$o-AG!0AZ*g6A40NfSeAV`ZtNBlV5kcjxu1Te37 zvNM@kStNjdeVBp-SK3n#!C8;Dq)CK8L^BeO6!9YAkijUU!f~PMLw|-HMQ~okTh}gm z%CQvfTDNT~4jGDZFiQsu*x);G`E!Xf8+te9o7$T)&6(AkB|Ab{ z-v_=~hh}_Z8;h0&aC08vXSuC2L+)q}3MtUk}OvtQ9N=lw{%TojwgzOYLg43JM(9#IpK2(;`nr*&5 ziCjuz1wq%RF1YUx#|GU7V7pmWser|^yPPPJ$l!B%J(P1w?Q?(yULDOEj_0v(8Ol1fqGBaYiGL8q7)TGBO3ACZV&ej|_2#wrQ4eGN53+1TqYg01jcL>LqzZ zlf*759Z^^>zM}fkO&H>W(F0Mq1$o{R#5!=aJk(WH$;rtjUr1oiRiK|-q}%JET{z+) z6~J~R25y83A|jwqEP&CGVxFWeDKB;>v>U8^^m#p@r3_n()4hhl2ICG+h8C3x#(y8# zP|m1ZD#H?ED#rYfQ+`S;PNOauC%`u0vTTnq1{r6kcgq56?h%ELP4XVvP}SSCE;&LHs@k(6rWu5{Ld#P<42 zL-YTM{(q{8QG#i_xISBGemQUglb7=nKe#a~s;YRspXfL#EDY3lWton9-bdn#uaNWP z-!&_4_x-W*!}WD|#2+So6+I4*KSun7XP@O^tg0bmmHvCjn12>|+wl3kn!0(wx6DM_k4dnCLWPXwoVC}fmb|9lgrED3&omYOUdKhjHK}5M|4jyaU_T#O zGRXlPAamiS)7Bt0Kl;n=ZbZiz;9elMmUN%~sHJBc3H+IwwXKqFpFEmn9Kic<1d5fZ z+Lptng_Z=K4~zmLRI!NGmj`n16m=DC@G6Tc**A=MC-mar)tO3X`7S-HtU4rH1 zc+T&{&qTZ+IlQCTS%4x62x{wTgNA|I1$hO`^FTY9N?DW>PiU9LqULz)Cy8tuYcpGi zxKgFQ@5y;Zfp+k@4Wq9L5!d%x_NPm|we{7Sr?UgI-FrlSciQ(7-U{}*&ZsuegrCaq zAQ(AC&7^WBKu}e9K=5e_3;a%YU|Sof(-oG153~6Mwt#?uBaiVk1RCp#XfRZcmX_A@ zwtuhY!QJ7wBWklCiVTcs1TuHVN54V7=A<}Fl6QHZ*)BO#0(4fOLg`ZZ?RyNFaLj)L ze+(RWF6~~wj~zB`PQ@CHT7BbfaQnlv#meKU956`coIV=`CrBPg*a>@@t|#;)VeqMZ zie72U5>6F~(|-$uyF|VWA%fq5s-hwYT76g;B#ffIzJ7myf1lyC#5qQMF1 zq{h_YqRe2i{|J$r*nvPfN(YDQmzS6HbZl=*;1-ng2#(NwtrOe($UcU#8W$K4zmXxG z`4$gZDZ_@hR==`Uw5qa(Ql|A^w7bvs-_pV@&>iH?R4mXGlJ-NdHU7{gznBa`RTP#- ztV$2uGW?W+pwyWV;puJt5|hDf0I!|lR*B+2e&&qSzv?dCkS@x-KlS_{g_lz#Q|4Iz z3&Ib12!>xu|2^uZVrvIp-f^X(Jtp!N9` z_vxo9dl3Wl?kpWUHbd{l%+h4e&r3$QJiXt*pW$$r>VoGlMI&_F{O&fltg{pz2x_+o z=nCDD>EA#5I&^PqrBmWR-NquGItC&hE){1jzT2$VW`6c)Pup zG0?q6cz_2fi7Y=cKOTD_0As(+uGHboGt9?mX{!XLcja`*?~wF*{RCP!jis& zL&Ri~PGJY3XZRhnz2R}t;S;% zKOirrHujRjD4tXD#%mQjc&O5#gM^)grXu=EVPb7ZAj7@L!%%MC!jNJfZC{vMD>$DQ zYJ_~vd8|Pq*(Ie_S5@$}Yn;ND%ywA367ZPKUwl6%K7#Py-O~TG+%FRrx}{q!*@XcA z6WXJp_sK~T>zyp+0wh=CynBTb;{txULiU8PI>{v@`!USl)xo4E4#oWc?gDPMqTe5<6H z&Ru=*m1@W7ocjDNAt11YlwsZK^LKt$8co=K_cxkf+QtZi){~!~W1c>bYt`~+_py#~ zST}cbysBdp-P=MatldeS(5K_)LTGaP7r&}Lz4|z+0JmfNRM5}dKr5u!h-a%tGpm}v zUr#jf<6gK=CK*G8RS_6?i;e^EKV$)8Mn6IosY6} zB$hHpr=*t1xjn0x?d5qp6N+Zl%$cN-e-gI@v98e1tg$9RYIm&LXRae{A_wh*?7kA52YU9Zz8VE9n?S<++8F4% zt_>RpjpTo{a?Y6xc@HIJ(WREa?N~L~wx^1WltDV&v~7QoA3w=Nz$vB8p~tPHqA{9Q z`3qA=-=*Yx<6k@KqE16c%CX?5XQ%acuvXE=wfU5Mnf!%B2C(0>Z2_FQXy+Zp9Di?5pQU=kEvXQX zY|ueklCE>|-Al)zX~{WLC*a={z-($FO`IFAx#Fn?aZc?J?xMgbt?g@P@x9p+W3tc+ z&G=UTt{-`@Z-2sshMQwG!RgZ_Ux0N3fZ*+}nrtlG+$bwxJ5$=gp%)>_Jzq`_#lX9( zP!|pVo^u$ddUw?B*Ui*XK`w8I&;Q^Q?%ek$%AO@25&N{(Wu;5-lhFEA3|$nopu4Bz zYB_K0jldwW{XkR+)X)mjtM;q@@CsczgWs#S9&tiTQB=%GSQ&ZRdgO-Ntc|Sug1%1c zTP>(FW{Zo2nY`4GIwX@}s(Uq!>0c_x{n?SmLwL$mrfj3E<)*GvaY-eAY|DA5yMxVI z{mh!IUoi})s6fSF5)K~zlA$tL3;>q{`zqvBXr9UiF0jpJz7yr%h*)cgT_U6vpU+n zVOc^}8HV(!OOt{yp+~p62~qe&-T+OZBQUcAv^)U~Ihl~=V5Ini$>7nx^Vih@o5!ca zmzzCR!&3GLxYpqzBRY3s6CTDT!HgLA>^2pe3IQ4Y+3pq|_fO1zx6Os5xiobNd09>C zt=o5Ddbg%yP5dJucMJ@+WKeHUlw!@)<&82^nmBtQ{j4TQ1hH z6+IgEuLs7-YZXdQU7v3Liz?^r0)WeXRYk2HT1rWJtF2|GIm$0-JUMS#vm=`t!x75Y z#pCGuV2!w(Ri#qydlNo)6agAu@Tt=JelZhs9m5y*-W*;v`0yTiPom%7coqntk?4=s(QtWpb2T|)!G{iX%aJ;kUow$HSw+AYGN_kbdjQ+C>1HLRE1!qMtd<2|~H3*&}^YZvgdbLgj zHj-lzof|GLyf1KXA&JQ~THgI#bKi-)YNNelY`M$7e<`Z0usNLHu2XqAzi9b`)8b;s zzj;iPBg<+r)I)KDVx-MA+fHc^rf)3Byn$Jvzb-)$TF(-%-7Lbw9>xzkJZblAzbs!C zUUkZQ(A-=T)@TU@HMgD2s-JA9WA#cwjiPPSW%lxFa>?T@dyOF~N)oPw_|s9j_?L*) zjQi7*hIn{m%Bqf!wz%H_$c50$E_4Ci?j$D8IVZ84!bgsoe0=;}KfUpF{5Wa?0XJVB z5J@K)A{{4{h0!kDCniZXC@x6QmxCSJqEXfump@;KcPUy z29F6(PAwMpXQGgo5jYJDrt%<_jc)whz!&K%>s3=fJE2`t*!pF|9G{&s%TwC>%q9EC z*I-Sef1X_@AE9AZt^NIGDino=q?*QM$D{1@rHml{lh{GePjehXPeMb_V97mMH$wFy6)oA!67@0$Uv3_AO7Ni4p!?GIrmy|0;`ibh?6ae9 zn!=!!$cYr9o9UVS+GJv8p#BvmVX-*OB-DxgKWSuT#Xh=^`j-U~`Smc_R>~A;CnKEL z#O-Y zKYTCZ9Y+XdW(*C+E{)bAh?cLcmJqNetdzxw-KZxoZs0WF^5t^Ysi?T+rjt+{jUR+1 za}HKaPUL@;YUE9_H`4A$m>`Cr`1Y;flYf1u=XnYW8{tBgBJ}FRF$2^=_%4Aoz=G(r zBGNt;S5eg~@qAXnj%sLsGLH6Rhs9_$-JaYK^CI!jxtl3ZhSoB<5^FOpqsya|#Ked| zvbNIA)nx`oJl0{57VDG$!Vivu;sfu$Ok5Ur+>~6Zlnz}8`-KU|UjdTcNinhv(((UAaTxh!f&D<-O+@8H;br;9Y zWZ=6!khjN(|CsH{;h|*qhV|jIfXUiT_0Y(x!2HQW6Sw|gf(8)a?{d4QW1VM>eG!fd zN}J9JwRL6)>!Fb!Gv}D!Z$f|xraHO5QnLlRhzU;i*-4Gy$*#U%?x?@5WCiT;2pVT@ zf+BkTO_8sLCRATDC`|g0o>fiPs>KJ9%wD;W8(n>A*kGf+XWn__I)oT;)x{wO68H>X z7OC^9!VfV9?H-^{lwl@UuRjb&<`cup9+f9zMudR0G?FqIoePh0A|#QK3_b7f!o>pb zSyOrg-`j;7RQ`?;Sb*>XF3&$pfd@!BP3q8b)xplhq;Ei|L{v3cCf>c`!@K{`)t1%B z$j%CfZu2H{pQ^GN|AB&vvEMyQqwr! z1sSfKDP6=OsR-Qbrz9(v-o9rokJSzW^>q}YxFMOS4-vj2MjQ)=)a4$R){pBiA0b2J z_jZ&48ju+75yMP*$v~Y!3d5tUI1+}bFsL`dn%CHQZZe2~*6);F z3{^ovtNkrH&Ql$L#Jpn(tk0jFskUH2GsLal7@V9LZP7d2LEd*u%{CG#HA%$-x=a@2 z^oHO^2Suk%Ygc#;fq|c$?$=(1;6^~08R{HM!@vN+{Tp3ic>Ix4H2xGm=t<2h2|avH z^8P?hE}YIsuAc8v^@NS|`_JYX4u^BdM{^H<9+JaE^e0!LxH7I<4DtbQGA7Ds$l9!a z?XpGdoExue_@Ii$*~^`3R{P4qLZ?~czfe&i?i)M6OIltD1L)F4I28O^v;&(_zgx9n z!0-v}Fb04y>-d8v5VcTj;FpKXagrhl1rQ4&r(*yH3nyV2gk_P5p?Mdek)xj!4G2l` zftgIS;1Ob37`h?pz;YhtDCwJHeW;c8S8G1>vPTS%yMn+PbiCl^7PAAJ1`M4@oK4!7 z8yOoCoyD-g+I85-#6?p>jwz9G;DJLyAoZ5fhtg<#Fo0-KP~%z5-)a=xsNk4zz}D$g zY2_Zsq3Ixj&FR2vWSmGMYtlha*jL{RVk_?Wt@FHmwdQAY< zH|wzLiD0-P?(Mx=3{d1(#BHWskzfmRD| ze^1sJ)7ToR(ZRgDHBXUZ`UKZ(f~rKeO{%W84+}_B9maK<0CTtyq7`{T11}W?vWTlY zJH1U!i}Uk|+1ZzhAey!5lSli2@YhZYSyvvsSyFd3^U^-K&XBOP4?dW*_3|Ln&>&?0bYZ@aq8*7!|ACj59-UGLIEGuADHhLApm3Jyf=FxrM6>JB&9EpT^#R$L&#sr6Kn59Sw?BS0o5+RaIKlLHn(>i z37L34;j9fmn|40SK@ZXyGaTO(#Q7OseXKRmT zR*X5+24`fX>Nl zDj8dZi^=Toqzsv*!e=_7vh{3CQ0zFB5F|K5-gKHPgtXhtWIp3kW_KsZtjaI%YOU3@ zl^)`noqig5e7zB1yZ^q_h%rASFsbm~ z!@&j-A-7Mn5J9fqQ12mH4QooJ+RrccYkNM@YeJPJYN1xuT?;&JK&PC#v|*v{aCKRU z0H;eLep>FlGfA*(dRC}v6?>-2?B9Ud_xsyAervufQf31w>zY%V0wj>i=8`(ah1ck> zZktQ%>a?(^&v?P$4?D5v;)R1ZXjTLqGznac1?k>$%rG?gD2^aOnW;PVU)0iy;%fbK!A(SZ z{X3h%6pp!2^{+7KeJLnkz<%xeT$bE7Q?(Y_UgJtYyRT-zZ43NXHjcMl2(WpDVnNXx zzI?8C>AxfG1rPKL>C>fOTSivd48)B8@xY~X3B%V2_5s1s6QKqRR81D z#MtZ&DXt=+k8Q8<6<(6x9D?$j<%Z+D3AZejX>2^Ac{8%KG^}gH&XYqD7n(eR`F5;! z(QpiD(Yr=f44XB{0)wJX&8S2lXBZ`8?7bNir}A!CdE4PNAEh|5F~Kcy&s>NM2j{62 zNdM-#f-W$Q)7)}#jL{#yUH;pi0#jV^v-)l^tGR5-Wl7SN;88;dZdr)BxN6eIUmw%j zM7S;3rCPQigMcrqTHbjYJ((z|Z$Q+r1`{E5h1ipEvHp-{Fy_>@)FjW<1?KWcyL2GJ zOj22^MSx?C{&hNZgPZ%-YcHd!pb$TZV6svN(oGCrGh{s6W|ER`*@$6BECH8{MHx2}4LOgHMB~1A4_rhim@1dzq41)K~RY?fczb3*X)k8t1V=RUNFf zfH7d%nAvi-gL@XfiSpGsYL1$*e58d9{dzAgKes^(W&8oYnfNtr+*uEEQMb}ZrZIgK zQ@iuI+O5>Q9D>+2Rf^M;WyLCW?!KTt?lSyY$yDOHeB|^8(a%ijTg@+V6164$;UhX; zhO0xq?G@B>hAb*;nF{1pcs9zCj&#d(h(<)}%6@WY_6*;*rT9p?(-H7_zsJAXq9oJS zOfO~>=DQ#{0~mFHF{H0$tUIFs8XNm*OdUv?q@W3{|+dHDTmhqeXNx!Fq zLtlx`-49gW)^J5)yh%I@l2vSlSc}J8w_`~Nmmteq2lrBX4C2+pnvX>dg{EK4D@~l} zc~Qj2_2|fQ-%|V+q_HS|@1TSPqTIC=YCQHQ4jZWMBVUDP-KX9w`B;t0*f>EyH(JzO z2~hAc_L}S#MQ649>Xo2}nlfT&LA?RxL~W2^47|+dX~u3p zGo9eyLI*~#L5Z&qSWdVHQ0H9Lr&-~YwPacZ9z={{qKGs6fIV|J5WFcqr1Gl| zBlG|UaE}7ITCQY3(48SMSzR!38X9J%yZ`VA`XEL~DJRU?Flt*#%FX=M~D&qap+&3sc>R~7Oo>!B0jMH zsQ}ImmN+;#H%5XsHkH%qXD~1@f{xp57`A#Jy77ei&eKzo21ge?EanfU%uHl zUTP9(WN_d@4h8g9kEWYRlTd-FXW;vJ%J$1PEYYV91g{Q3WtC@Vw9VHXO|7H#kr2B^Ve&qp669 zyrhT-nY^8~v8ja-7?=zAS&E60;u6llL>nhRxi6XL5Hm#U2TpJr&G#CXrh#G|F z2SE|63P$K>)CnJ0(0=#KLdBxCuFcnxbv&j?rl16@@THs*a))Y&|Wx80eZc zo)`m^0ui4kk3(&UFbzJbpTn1!k>R^8tU+jG;H&D_UV7%sDt&)8NldJ-3`X=5x%f#s zYym0@eXLOURodVT(gz2=GsMW-^a6&aohDCeQ08x43J^Ai5WLe0pVXTZ7pxT?Y@;G7 z%G;8N^rIe^e}@{(k`!7kRt&Qm?8{1(57ciTaCRR?X0ZNFh($1WX2eDaxlLFU2Ijz;Z9?Dcqf0IPH=hDNFVV@h~+NVNyz6;utxa6&M%iptuUmW zDsX<6XwW$#$I*<2a4Ev`(U7|ku7G!W+Ft+dEKyqGVZZAviXyD9LG{9OlU|2tvru)y zTJogqaLB@6CJBFI1?sVm`qb%(EMn9LY3T`aAw~LP>aqNR#O*rSEVuct2}>HJv1Rr6 z)dSTFrXy$o9V5q&8J(?{WQ)uf4i%k3pAwZ$oP%6>&>F~QhtlM?DwgpjlLapvLmby2 z(ze${uMtmx8Zs`3uJ1K`DU3Vts8h3lwWqZ^TyIGAM;XgJ$w@>+CqlKtDS`!bJ&Zfn zO32xOKxcaoITuW1K$`DOuh?d+%~B(VCRGKBGr>X_VRu{K7Z>bS^mTMnpQygDE3wCK zk8h8PkJ_)EukH}OSz=WbHgM=5C4fNopQK&)6??$&{3PF~gFuK+C+7JQYI*wIBiEqL<}!+BX3u6_kEQ3#XIo}< z%xKNIe)pP{O%}}^&O8_bzS;iIeJ%dj zjJ6CRYk%#q_2BkU0STS54#f_Qk6Pbs;bh@#VgD{-oPfPvDLqT0bQ4czLwq~rFr+8~ z6uCmV1DPb5%9M3$5Z)3#d_+B-7W1=(g&C1$>-b~%l{uzooK4&u^%ymiOkql3Y6ZI` ztEW|gIgVNV@M+!>y}joR+>OG`ubT%PYMg2uY#c3|4F=0^iVV(b+I18fO={ojOB&n; z=kbZx1Ct3T^tf=Cz4evWi)XyYyhrv& zkIVEujYIdYsAJsohBfQOjalz(Z$&V;OL$sDJS0OHYFH8!BIIqTa%2Lyd1P4x0ixIM zy=;aegAtjkf+1dJD19K9pkILk2(>69h$q{ujkc@)t4v$kTYFm%6r*44zA%m&jYed; z|2}^h^cfr;M9+Y<#RaD7yIKf9 zM?{qzj1`r1FK{w;GVYj_Y8HQ{9N{nhYHopUJr&Y79M%`+UOQGJU6Z-%vOl}q-DKNT zF71%Spk>Ev?X-U1S}C2Ilrg+8JTcOtwW(QEs_UV)S`!KI?@kDiOMGFhq|d<#342x< z)vUJcTO4joNG}clRjzh-V0M07pr5|oy*)q$P8B4rEPW#twD4F~tTUq8Vt97Rlj(?j zGBHU}7rss+^y(Yn^l0)3W;u|O9hN^&ijQrIL^~;aBw4t;lmmL9i(^Yr+1Zr;pOgpIkT6*p&P>~b^Hxv=BI;|zXT zcQbQ#b18FiZ^^g4`AYDP`qR4A7Wd5U_Pv+&3*2*3zD?(>{P_|mxpXNl+qH!aRxfuO z=51ZMw?EIh$otd^eSPHU?6K!9YFr?y z{U)i6$yw{F#jaSesN=$Han2kv2C|`|Pn)ovu~mRIS>SPBd_H0-4Z0y(qpzX3f&TpW z>XKuUW5a#MU0XkSQnvjAm`9NhIAStazY zC%CJQ&^m7P!~ZrPKh6+?pD^AsHysT1qV@l#U6qwoZn+~VzCJX`clHW`3ku>Q^MRtK3dZz zcY-pKxA%DS3xS^Af9lA3J_d+|@CLytW|0w~Jfh+Agy1OSNN}I2;i`l_I^AT8kXzQE zXfWR`fdNYjDVrJO<1u-ZRMII{MToTQCeNWziTg5y_1_6!A)nb)t{FN$%)r2;l_f=m zm0Xt3+D=pym+G0+Hy*reF1&5--)qhb+vZIR$Aj!2mgcP192eG<&0QY^NQ%|-`zrDk zzG5e^woleH(*9AvLK`U(6^8P~5+%82r2vP3f+I&Gix%<$oAQz%y-6SCz5#=-dzkn7 zHbitG(0|%7HGs!<6SuLa4F%yVM26->m>G=$_fH!nN{05?K*#u}J2?8^J~9Bs|Lubl z1^zn*KtZ{6Sq@QDMCa4-snY;%F7`CArqF`uV{0Zh~3Ur5_f*6<}w+B zIu-$aiBHD^evr-k${AeoimW+z;KY9Mp`Z%(W9{j()5%F?`T~WqGeI2juhSn;kA$fQ zIvgoGxz?FrcSIoziiq*U{L7sjQme~1hb4cu!`zj0(}CPoK)q554fJ}9<4mQAZl7?VaNT(j&`QsIFp&;sa11?)`h`R-*R_t{A18}>xtv8CicZfHB zYK`_&s)#pZZ2mkpW@dY+f&qrFs7DZ3d{5wkZx$S->gR+ff+_h+P=#LQDH)(M;0#w%+wNupCQLt{~hqw zu;{EAh(Nl}L*PO!1A{;y_xswceCqt}b;J>IVkhB7V$GwY(Fvh{mxpcbA$|T(oT)8q z1L80P?UoSd7 zB`3REgP-D+dgk&44bD9!I-cuihgkA=Ho&&HA*0R?xt6P4=vXW^r26IQi{Z5VS+$M- zl4t6H?})$N)ZvGlvBYcEo_}X0(3Vh1Md`TO8*wEadexLVCs}SU>8KcYTL|+)-V9=Q z1{ggubw63rWs*8L=8gCKiEa;~FR33|P!7~#;&;o6MJGKMfQG$c<1fkvnWD5Ca#M%j zs65LG;HT!jgk6>bd(BYk&;FGg0dg&8Qb|?gDVjXFiFDu8b_3>e?S%yw7I+wH!GNi_ zVKwelG!;u5OrQb{qZ?72zLt9Fcwya;w!9|>40WX0ea*ICvxftP`JmN zbq9uk@)&%xs)zg|O=z;)FWl_`Jn8)8o=zjd(GIO-;y?q|>_2jn;;8*tJKRS@L8;=1 z;Q*_gb<%kVhQcN^~@cL+9;Gw3`HP=BeovZpT?IDntgJ&UF+A2%?M{ zL+U5=6q_Tl#~EVsx7Avs;ILyr0qB%zC)l?EvODS6Oh zh>_1}crp!@>e~`JIXc|Mg+1`;U{l4EBki|K99+oSD%((Db4AI0hQLT*^eo}@7T@FD zyc(BFIhgu@(g!2?!WNN3uqYaVA}MqS7Cgpu{AF?mbEC%r=iq9W)%1K~X3oAfh!_@$2O_*!E_xEF@6A~D zlPEkJP|Un4(|o;ZqJDrsbRt!}6-=tMFJWu@M`WF1qIZAjrkm&%(8vk}jQ3<+Unf7y zi?m}mo6Hg}NF@KH^Vw(F6aJysBvS`(#hh!^<|trl&^mX+@2zX+Mvcm2Ha;xjEdu{B za^H6s$BZxa8}X*XtYn&#hbH6CWbB5hfM(^puxr+|7TNfq+=#pMaJ9+&en^ibENy%b zEIf!ZqbBYA{b#Mi{Am(j&CL*KRwbHMCneh0E1Dm@?e1wH#~&idG)?Ivi9tBf0{IJSi>k)qakNG2Ooh>6oh4U(94{ zvnkz^LVG!Las#yq?1d>kpr=f;Y@~20KE_+S=)rU@cG7gdo?^Z}ce#dneGAlhK`3Bg z$?HOw&@S4hEj23~YBc$GKosZcAQSUBMbBH{?F7R2@+aD~@QP%S_w|=klfcp(CMMJS zxB5%5IaKB|L6nZ=ab`#-l6EeWxzTV;RNjA}Webufuk^e}a^wr~h3?rK;tG6EM_I8`bSG)L-&yqI^p)U>n^c=HK8y zZrj&)IpjUAk@ED zwP^XDfqK?x3hV^X{{v!W<>lc2z!zLgL{xZm&g9G~#KA6Woh2uFx^G1y%B9313?&-e zS3qc=epZ+fDG`rV(DjzlSlx%~D+`|vE3h_A6-7v(^dggfKxX?}k220dVbWIkwKmZq zXd=oPz+4Zz(l%C)uWy8Lj)N;=dL|Tj6qpsCPZ*rs8-gUv62^wLH1hm8!v#SB=vFYr z_r-|pRU0j~a%~9MGk%HXmK3oqwiP*tq5{jxzATM6DaZ#~+A>?;)$I1T`V2uq2X~aD zxLAJ}8ISLA6&)vTdu55I#uC!P1HZ?R(bGbq9oE@92*ps58Z!^4$L8+y2gj_L{NT!yd&C$}=Zr6OvcMvRaXi{+Ywm>BT9j8hJFFS1kRsAyl;eymNF z@LB^|Y5m&w--ltu+3$apHzIsEMo>Rcg0V*#uAIMo{jexM$ix^z5wP@qh4r`j*f$`5 z^=<=oZ3#F1P+2hvxDW3D`K#!3=ag7}i{yv_JJ}j)JQQPu-aJkq2*4BL7lXvSoDn-} zK6GS_s_0xsiXzeisFlgC-^;4uW@efhKWpw7NgoLaYC}3RpB+P#+P8VUO@1Mz$+Ii) z&r}cEIu;X~$S%8Wf{V!Slh@W58QS-f6GBO|XhC%*do$JuSL54kjY0Ifq?unk3H#EWaVedDg2W{zs|Cv+dDI+?!wLJ{swnZNHMj)M|Q zk9P?nKtK5gC)S8lrou{Sk{1fLibRB-&mv2l`B;$SiTsdoOD~hu8mf>9qT*TnUTcts zZ+5IBNZInGwTiGGCo$`u;6;0H=jn%YeN~kS^UyK6R;UnHpd(zDSh)8Xblk`Znz507fP%Zwqzqc`mmd-!C)y&llG*81>TYAig01< zm-ldVmIBYt_7yjirHAjTnwsD})ly_=0&sbhdAV~UlW+P&e~^gNJy$PtW70hKbvH6c z0!eprg)5Cg7$isgFzmE}O%~rTP11D2adodieEnYLTQIjl8WVU&+>Z~LI!E{!c#R}1 zJ*sor=Azt3SodAG$2{S8dlX7rbmgJ$cC9ukCl1sgho+~x<)aA7x19Drh{yF7or|U9MMdX< zq%qMYZwR&-$2)u0efutHUXS16vx1b@>q&W&l%5ujFciLew!8HL^IDOMEfK z2WC@+fIm319;fK31is+j=A`!j+TFld>V?qavs|!{(Fpu1n{@&nW#E1bN5WrobrHpn zE(_4&xQ&$Bx~f$f<;_n^5PIB%rz45-XPhr+>MA3Opj+TDZj3om3-ND&=MN?crtIzD zjT}+uANBdFXT?i!W~$7`5`_c%03^y~yR`eAss{r>pVoYLwLRMU7aRwp=a+|Hp{=H7 zBCm2!Qro-7$jC7vh;;FxX6ieO7t+_1DMtLz>kX#4yUatJUs+%7DReM^TRFV_ys90f~W5Y|87)4t|1@nPyoNVG#Ox}EZgu6Tlp=WtQJY75s4&0eBE{MLTHXUK*T z(eo_BIgx9vC-V7x#$`BT<*{4;{DG=m7-5D-{7LuDBh&5yqnxjrDS@`ij92=pYy3ND zDs?(ww8{{n6ggT`ZOT#Hax3)=#F_uzvygk_DU&6|j4f30G2mSqu@G}EKNQ4WyYL@u zxyH94A?l@_RJFDzC?z$$n^{HgbHtr+uQQ|zr zBbq0!1i{@2>MsoId0^j-!5tkn#v0qhScE$q2a0rGEH(;pl;Q~tB%Ml;vRuR_)HM+V zULF^4$QDJ#{i==kvd8{qz%ahgDkF!upx=%o8=YGUV~`+3=7ILOJC??o6{hM#IJjcK zGWMO_&a<@pVrm{`F3_ELHlqCMgx;WSRQJ9+G}-D>kC?~>Z*r_>W1)1o2ZfWrIzHjACG6U{vIw{qmJZEvmg7f zjfc>oZ|I0!V*n~a;fAtzlQa3R_1}I@938Pi$l-Gu_3c?!)w6W*l@T(3-S1%8n0N@-6+zx9^MbeCUqj*64B45`7nrRlxccrj6Qg$(XSug4X{H2Py@c6%u(xuHJ)a2V_2R7%J0&RiO7re|+b2BZ zyQ;ee`*KfLwOrz~SDY>Jm7wy9ArKY&lx=>mp&sbk$e5AduQF-!| zb@TitjE9ko)wz<_kfHfd9*P(QZwO}%1WT@KB11X75xIurPZDDl;6e>CZ?uA1S9j!B zY1n>ENdo)BA%!L4OdiaD`}F=j~pVC2F~iDRunZnPt7s0kIuwgpb42=@0PYK4tZ z*zUbvq}#iMh)UHL059%D#LY1Lg*ZZ*AZ5mmNhjRxK%VbK^z<#X2lQ3$qL3KgWp85+ z2W5N}he~teItLTcNx3e66$o;9#5_5>QtA6XWI0Bg7gENh>=Ih-t5&fbzf(|Je=GG~ zH-ams`4*t#J7=}>S@ZcY9$it>%TdYz?i7~Z&y7|_!z3j{S z^gaLR8n}J6T?N&&?*0w&oVB^)bvboRou4o;u$=e?CQ&YiVt)D6sp~Lf>exXLOBv_# zB?;~bk}6LWBGe*q&m04veD*5xmPDqsDx;?Iag(LhRNrY(WJk9HHQR@n$Nkg;CcquV z!={FCZLgkxh8#l?W43JS8)foy?bxIB8@6?4?#V&pq-Uw}A z;>;iD(~`X5g7GuIl!W7?7%3VeKo)*D*ps)TGHqM=M5>N)4;}A8&;*W zO!iDZ3?>x6La2yDli!&f>w=@5#My7aHKnj&*~)K45!H64nE+E^_NG>zrn0dTy>-ZY z;d$9HqFReRFLC%}a!EHK!achUg6C|Y$@U~1&Xzah`i>o-9FVJ0=s{8#S{m@+1>$j)kwyd}?H@o;90Ds() z2Uv`&qH~*L>*wI`x~4=WV~qc~c&SS5^r}@r?zO)pev%!VKZ5R<&shO759-v`=nbq`^48I;qRAHB3^a!Q?+VB9kCjS(4V0mbj)rrmdr zsid#knOjYIL-O2;qoQ!tYhHBW;oFG6FEy&buwU@WQGV&Ay5ODxXeM+|iEm} z3aJH_mZ8CB=*rts^)L{8OOsHWi7cMKruwoK=}3qXYm|9k6`(8ghuU${my6!nB|zQOJd23&Cw+m%C_s*`rkPxeYh z2GuT;+E+Jia2K&2T?c+_aMR;debgb1m-Nj5)-Msy^(9CJ>mntK=D6qnBH2JUSp(8R4R@uCUMkFKa%A$9{O>1U za-XT{Ro+$}Gtf^zjlx54^yvX%;P;#Rycq`!x}zBM?LNE8tE=&G2-{yNUBZDbh_{PT zrHO_ZaFUwybaounf1DKDfy)ub$mAhMb8NG-j+25czx<>_nqHbr(9!w1w;HOTXw$oo zL-K0L$BpNH=^at$j;;4Li(?{b=SitChK7`yav$3{ij<1VhrGy0-7 zMMugc3M6G9w`^Ymd6$1&Nai^-K2$av-rm)EQU_k6-w=U}7?K{)8`mDX&YDq$d-(I* zYhJBFF zfxzRXVYD7GQmbG)Qvrjot3|gAM_~~_8wKOOfwA^AWdO?*D(axRinBm*B3OEHgSMm% zbxM_2)KA8`eSh>8+k5oRs*6f;)jN*A6GwUF!N2W*MKpJ8^So&U6$CWOEyNDZkv;e0 zoHbRTp}A$(G_n1I7RA3W{TpHmUu%IJa;CX})#FEsrBG5#I zUBWNO55TyOhS;W4D0Ly2Rka?IPy7>03+|xsXDe z6BGGjq8B}nQRe+~cj=o1V%Vn%9%5|YM&X7|Gh*(KY))a?IK?icmqqNZ{lB@6%JvR6 zdUH*XZ?T+zJa2l-xxT*Wki40^qm^H=K|D(vB)|+v?D^7CG!!Xil)Do$TnDmdB8!m@ z(RhL4q)zuEf18vKfgI#1DYtxi+_zMgsd(Fi3^ifj4&)i>O>INE=@~ecN@Ov%CBH{lTyy0s!Lij z1n?NqpdU=Q-#+q@pLC$OHEl~>$Io{5pvIkGT4yUX+Kw2D@xUT%kH&@c|6K@ZToP68hU@-*vAl059~ z6*@_YWw+lfA~lo;I-K`2;j7ZGr|yAnmV=EJDgP}kpPTsK#(yiy|KERFOXO@iHg)bDgD3HV*&e5rQR3VJ`x%bt%@gWjMf?`y5AF^M&~IYkBxQvoQ*pNin=j;o9LawyLI-w`92DcCJv2ePzt9zA z(H|A?d|d{S69QaQjrh$|L{7v~FcgSZBc%pMe!3bp{jza!K(b;7LpBQVE9@t8vEVM_ zqv_oSufkT}WhSwz_UluJSyw%FlJH1hMI1GHqO%+d$dRG}&GH1lWKEla)8~|70=h`5 zLLDbw>Zpu}A;zWJNlk`o0sbnbAwH$x2sfe^Qe_g1GXXQW(vm>aJAYp&9?R&YQcKSI z%F!*wqjlIis$Xgct(Ik0_p&;HkfV91m;<5tI21pb;??5YYFh!kP&+FZ4YU)S1I#)s z&NjbrN}j)*C_h)W?5^r*7dvpz-iG(LWZY9 zsGBG1;j<_qn=$zaP4CY7s^>WLCEJJFxk41lOXzOIVvva^J72)5O|b> zXs(lS(IbQ>ja0(LGPJ@8K^S*sY=vk%ZNmHMP@OY7U>fCNb37<9K7>+s*ce5QFJzmD z00I;Y7ecA5!E-1b+U^Ji5dH)oHl4dZ&+gZW3hAt}JT!}RMY@wLiLcq?7(cuM>Sf^q z0ce-aU3v||a+9MX!&#)^t*fe_vC{;eU95ROrcHlc%EY{v)ovKLzHKzS1+&5YJho&& z&(UpmyVo}(r>Z11WmDBYlZlqN5^^54&s6klJ8X3b`cZ1wQMiovlCEw^#-6;AEOHMab zJBdds&ZdA+`qe@T{I^INAu~xoiDMNvKk6hZ+?$fN=S&EchC2(5IOvM|yVR4mk%STVq@!pYy$nvjFDUC!O&5?gzAk&&M3yRMn zou))`^!V`(e-`0-npy>H1{uzO&N+t`Dpk31#<}KEXP|h~s!DLB$aE3&=`Jz)*2tHu z=FN00!wPD0#d!y_%n*AW9Si7ImxBStBjSWzqE&!aS1B%A5dooYoh3^XgZ|xsx)?TQ z3s+tdU|>HJB7pTQ=nO-lJLco|g-RMQEGM@vU#L-b_=AgD6lyJYL-Iv{M@&n_Hxu8E;yLThZ|YCT z^P3El%&Yj~QOJ#^QI9ZU-G*e#-RDRUGmCEHP5VVDe-NkL>%nAug z9`nll>XrJR%?23)&6Azii(;jNma|>^{VEvu>&{?VhypMrhdfw^6H{6|~QKl6zE&~fBL zqtLyGJkE=m3JJSf%>pgBFF>8&hZCds=txG{yKU0)I^N2f;CuTi0}rT$vI< zBb=H((4`iiYri}1QBJvE`lAW;3n6_^Jy2g8_ASm%cy8Z$f1rKl92PSKmY9czJ|Ip{ zvUx&sQDyX-zk`S0-{(nSN2i)25rmQiIgLXwzUF<6B9W4|o11b-@aE zHk010n9KM&N?!uqU|j!=fPSVvufl)9>h_?dB#0T4jCh4ZHqBTt4=uA+W(DO z2h!NL;EFz67B7q3pZ{G%2na-pZ3M`_O_EOp(9+TScJr0Psvp8dr~adwL=;w*``S~pWt^QkIU!f~F zJ9GXUpXCiLga4fIpQx^!3or%<`Ui?Y#E}2Vom;mCgo@|>n<{sTS3{jwSNHuto>8R# zxIFAIusi@qfr3ww=a0S{hY$Zzt zMLXH`OMb>n0d5m4{QSsub#=__>`*&9JIvp|L)X^U60xzdT_3dgG><&U^10X;_Tn-4n$9PLEL`T~oNrFI~EXM5b(b3&oRS+Nc zTvr={zH$OTcLaN^->Wz>z@wuTBA)Ixr5n4>YxN@uFIgKiwmfUWqAp2H3}>IpFkerW zFNYGpvRH%XBY)v_n{XpkZFl^#5_!W>MYwnVOT!sL4RiWrp>hUL^bx~_R;tUFNb$bU z9$(w|tvxw*{ldR#Z|v{L3gacr`SGz|5|hrbJFtZsn1wtWhQ;Z zan1BZmdqUBcC-J3KowlVlqG&)&Nipmi=`4|`K|jV)y{QZX`KgxpJR?;_?~Ef-9kvt z5&Q4cyu*xA19A_{gf();%5c}z2CCzpzL(=0LmS$>T`pcK&HQu;+j{3jrXXz62y{dqYQgd4zXPwTjNb)wQ$tgY%#0OHA(_$3LT+c-_^Y z1xf%b!Dk!~=oCCOZ_n4k3rYTziZdhc{Zv_Va2xgEgs(ZX%w=pu&QY+^sKVY(zLq3# z{;lqZHD#X`k)@*xB`6jUZ3QX|-w(Y@Ts+EaET<UpZJ6mokca%%G{8;c7c} zJ3?NU;3V2HT|R6JXpjO<>pobv`EK#dtL3ApZs}=E>O@Ex`MY^RbIemz;i>sYJbBR? z4VN8d1}1#htKwt>xbsx!$+!Nw?m^;FIq$u zB`fzyiUG~re>qhy>bgm{)^m&_ry6*O5jt0AZs^D@z18)dH|R-lcX%JLpn7d20zNE6 zTdnJi9@R&TzNq(5$Cx*R{tg0H26}#p$9N1w#~R*k1vdYM=|8~LvowHQ{Vg4OqRZ25 zF@Qtxrm%F#bgyz#vn+m~XJCMgbMWQQB9=bd8SoC#Yb{6{_+&3ojXgcz$v0weKm}p0 z!E>kS(RFj7V|Z_908L@8QQ%qvFFEY(gm|~!9};w;H99lTfk0p8iO#X4wjyO!`14uJ z8F?YeJ!WYVo0PBZ3LQg%hgG$Qr#J^Yk%N<*>oK1G%~cnZj09wM|E4|R$I-P9rL;N5 zT+OJhiVIISJhR$EsL$|KBGV?m`ZcqMI&5KhTe(9$_J)q0(f>0#xDv=|BPWO2>UM`9 zE-wC9t)*!UG;sx8Zsq3Y`g?D`%kW&;p8hNL1_=Kg2492$v5BPKwXA^Wm$*+#R)blU z+nOFyIJ|@2-2Zrke|>w1+GF0Zrok6}BfT6g?B%2n+t5=i|D)<>djzr0>w+r&{R&6( z;ZZ?b$+1$u^`Ps=p4SRHEhS0N4`ay00!_ZL^aJ6y-&16b%Nku*Sjg4^HXosTNaFD> zisjhhS?a>gZB)4v6#ge9wcHPGWEs&7L=u1b`-~p43JPFK=tK9MEMcH3^KQp6UA|*a zS&ta|tYmLb^G?2-A-Kf-xRxm)oWblbZlZBfT8TSb%YxJMD=M|}`^p$<(&;7U z5Owbxyw*zggr`?&JXB^==&=&2VS8D0+jB zb1yf^m)$ET22AsHvf8DlWDSwKdZ~Ama=(Gf+|ausVuc5aPLV8*9(wIOSfl``)%6(y zY4}OtWNw)?eil$rTCVe}vRHZ~M}sZu&Qk_xikhKRM3 zQbk`V8$98)>jby%%NqBwcQkhR;*R^N4>5h|$j6ny@G^rzO2~+A%?jwTUT#irvMll3 z1;Npyq6D4CuJhBS1OhiHXllf3m&LShGAC77YDsNbG#UAZ0R6QS{ZOX%8_hv`&(H}e z&{Co(@Fy`Kv`2vqO#0;NFT5nr^*4Th5Ze#UoEVnxa1%rmk1!2juns7)o!? zZWgLl18^(R3n8)$H;CZ%nCKx4KBEWIYBLTd!i{@`cA|hi@qTq~Ndoj(-OkKmN2PFq zMn`*7AjH7h6O$GzxeUqz2o5 zSL%gw2tH{)f$fu@i@iW;Mqe=Z0+-ag!PwBEcqfAVOnoR2D6rmvpKFi?%Ls*nGC_3n za}n_0y4KnjdSE_`(TK2zjf=86xm+@S!7YeR6y61f{>Fpnr&G)?K0=3=!xMT2{T)BK zHe^WUAm1{jJsRXB`Q4dPaof3deO>cf{BCvYqhIp4GRB0?oBp9aaiP^fIw0V+-BW&S z!xvL5FzeI767D_*FVUfhjuFWX-~LW>c_v6j^77u1H5InyZ}Zmatx)k%2sz1xH<>5* z1L#8E3|jK4uA;m(6ZSs)HhCjjH>KSl?!_K~4ZpP>KcmbS;3M1m*iC>C~d%2E`fyRU_16OOT?adC2di*=a!&F6~=iuIJzcr%u$>-?J5s{&X`Xu zm7PDsBJtGbMvZ6EXM}`nCgD94`h!&FS;S>@I204Mp!%D$3m&k}oM z*D}h)DGm|!SbSk-&|R@&qB#Y{LI2Kl#X6H1b%4NN$s&_zd5-pOZ4y*LUi1n57D8mg~5o!nG6 zkV=9Ja&mCQUW-&7jWBiWW=?-+@8t^2^GAjwGB5^#Y@HEIF)={HE91~+Mg~3 zv0ppR0t>$~VSgg+jt-cS0!b`Bv!^Y8_V78@-r2bwt4Mz4!b-DJhrln{8y2 zxq|cm=9dKdKlnxGbzIZ@2r^{#KZ0hyULTq zOK`ZJa*=O!5R!7+fjS_?pI6=&Ds{|#7B00|9xKSh!>&f*LOLuJ{P7RXtOED>+)_Lx_IP#yr7r11qdA#wof!HDX8sy3n)qdT>Fs65y8gaNQ_m2hZ z98m^8ba?S}Re3M>hpTHy+%3&`drDwPq?^-JUsJ{)o)iP4J#G*!TbnD=(7q92Uui+> z9>8T&h30n9J*nZPxhI^CQL%;rcjPJ*EGX}-MOp7@v-#+V2L~D(`AklN>Wdtb+;Og%JXH%&*=xeB)kW@6}ROzsblo?Kz=wI~Dv8&gArEdT z9(5Ks!TPy%18CCU_%g88hq`Hat!*iI-bbuAkGv_+X8OecPaD@6)zsE)z0@E=qz4c% z5SmCwY5)leL8@RyS`0-5LXjRoAfYL0C{mR!h*VJ_ARQ?Jg3>z#qz381fD&Hfz3-0i z8}FT8=lnS1jIqvMYtKFBTx-{dYSoUY%^$? z20AEvN4tu_0T`TH;}o0Do`6HOCa(pj!qtVs;s?b$YT1-E3ASpNfoofr@drIT->m9R zayc*3dgKs>2^YZp4ORTg4Z);y-%*=(N6;9T6H;}4^2FqxWc|){yq9eRFe>>agum0k+q0o%qu7YF%hEFE9e1ik}rUavDD9`Yv`|se0y1`m8 zfZ%46o}QlC0Bny>ppU3`Eqet5)X_qXjg&4mE#&pdlMFD;TTI9|#FuIet*)7lO)kN6 z5c?`Edf6l4G$VkBn>5ecRS z!n?26kB(V>nQ&74^^-(s)X0onl>{}NI`vb2EvDL&eO~yk-vNT6y%ppN6}y2fEZW5{ z!ygW?+TAEboH>RBHh?;tkWRGNg0&q@;Re9^`OkG2W+3ePb)Hk~?5$m0v42v|#->%7 z_B!w)brI1K+6+dpj(f|2c=Mr=5hnXk63_2N`G<;(|3;~4_Ro3-p1IAHI4<&?(8AbT zUsA%Vsj2B3fdDZtWGqruAj$nd6#yaT2D`HSYzODwi!&33!||iEMo27BaPQ;;8gElS zTcH3fs}GW#J09A4+D+>MXx;c%8T&7X8wclqbx&e1VUyWa5o3aMsR4{j`woo{9=Iv2 z*+;~skFk$#+O8rh?lP`r@3oalA{7KEph@$y_2Mvr^t70F%dCWohet9YN2JlZW`%f+ zca0%D(?O;@qW{?>ArnpcAq`Ps?YyOvE$MLF(EO2@tkHA}#cZ-(95R|ym#0^fsVwiX z7CMMA6WKg2rq8I){D@Yr+TU0v6=PCrYsSLp2uQJlT|RkzYJEB`V$9byZLge|uvQNs z4D16w>$2N-ceMZ2K==VFIT0eZW~DtmiBAy&qf4FdFIk3^$V^T%e<~8b0|HjK5_g8UJz~3|L?K0?LZnB#*g; zSJ&2PumiBqVsVkinO@{A1f^VTmBbZSA>QdmE*A@N;iP8iIOo7_hJ-)YXfq{AG{Lv5 z3lWhECNDV(y(s3VpA-Vmr1DMVw2P>z+=#q-FLDN}keW}wpIwaEM4e(W7}I6xG#d$D zjGH5vK8eRHM$Q$7hE@HAYT_nxWW+q?Q7cdYdm2z#~hU#V7`CW!O2PE23a=dEitX z7(|xuf3G*_2rUaPEMe9c%3SNpc_zQd+7xpbrLz8$yTrxVGzOYtA?b4ew-R_ywfe2U z##{d3TB*w;iI+uZv5fh3oNWzjP2Na`RJFW9?baeCmGqb5x=P zoq3D*aX=CM)3?xmXu7z*Avuu6)yJ9Nj9RcgrGD3-_M$mwy&*wx@?D+y$F5ak1>))$ zHHUUceB(L~Y9*WLd-}#uGwbkx34O=|iFVExCUAfJJSe7c-~06@r^{;s&VpCKBu2#j z)wu;lWody)3Fp%2W*-9{)uKS#sauNqK5l23=<_-wq8IZ*PlOeFg}-vX5%+K@s`46F z2G5{c%a47d*VO^kUfz~5c6IA0=wo#5p;63O7$!Xo)|kHLjB

J*;AJZ!q>rlc-QO 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 diff --git a/docs/ref/contrib/admin/_images/user_actions.png b/docs/ref/contrib/admin/_images/user_actions.png index fdbe2ad897950b0a6cee133a76155b6c299aaf5e..22d40e0181e5ec0acfacb709b878aa982fc72f3e 100644 GIT binary patch literal 35765 zcmZU3V{~RsvvzEAV%y2YwvCBx+qP}nb|$uMdt%#P=6RpzlbH4H}DO^`@pJ^>te17RvkxSA!e%W%a=(3AG689Ov#)ar(W>HJ*=#>3Fy7QO_ez z2N~lnx1$NxBW^w+1oE2?KZ;^P$)Q}3Rfp+rdL0P$Jdx!DR<3J& zDm>|zS%AY_PH|!LB4}LhmtmsX>}^3Jz6tV^MMFT6h^treRkRe8x{pTnoa&|WhOA7S z)8=>96H{oGOCiz86OlrvVg8m^T)mgm=a#)PCXYW2a5oe1z(d~?kWiJDgQA5$)51=a ze$#-tXA^Zp9Sb_u>X>4w`CiR(RUx}GAivcMhGbvT!BB_;C8|Fe zfw5@%axD8mV~w5nQ-Sm?cf6NgUtZ%MZ}6Yut@2-5#PMHSA6r!)DJ?*`O+@rP zfc^`OyKwjrZ^vD2GfkS|BLK9li(-YETgHXbVA;coA#7d|Z#vnlG83iUG z89+u^Qc+bl*53Lf_K(3(F;}{(wh_ek4@Il+Rlb`F!gF%|VE@7T=<|vpACPMIEcQ^h zPi8Kg&Ee48wfjCe{;OKM-Mips>4H;w@<|={{Tg{E6M2fAfD0qjp^t|w@L~D4OzTXI zkSfD(Em75=m1cLYI$eU1q;yx54AP7V5>hq2IHPj zkE}kVG>PiSbU8vrQ8@Z<}az>wc{6^9>&~xje9~BJJzxo^U2dz@K6FFxry=HDHt{ z>{Ve11wZ}!3)6b6Izi&4v4?&EO`>nid*}5ILIVLveud=rW4(x ze|;Qv(5W$c1Hz(4@c8_xhOZ~?D^j54p+_6oHee63D^fpMt7iiM4wV2KEm7Y3fPOG3 zrE9luYT!kH~}d>s0RCN(Hqx*DwBqP6hOw;EjVPM(#);k?|QkL!?|oJzz?I&H%Y z*KDjg${VfZTvxp2uChM9eDHkqRAySBSTGB#CAf3Y(v6Yet~`0(1>9X<7w%=(YC!^+ zM1_q<8119%)dY$)3uf{{y=)h(kraR8_VaCZ(Tg>DCooepP$;Zg4{g>JeCE>+Je&o% zUzO;78B1^5ye4|CHfl=l3N=vhs8aj_8Ml`fae$+^zv(StOdx9N{eXFuKd4u=j}mTU z2lEQ%$DsN}wGhr$m6>QSoGE}?5H5dQzzFlo4_2g(TcJR)r=;GdQc<8#9i}`t$`9t{ z&rd`ami5wDt}3kAs8U^U_8<=l_F*d6C{i6Z8~clF+3Xn1yhyWRes*;h%q!6V_!Crx z+RD-(aW57C05Ho;N!3wRT8hKK#)?+Y&_>^g*44`P8zun&aJh1P7p;sO_3&M-EUg_l zT)7GV>A~?`{##8)i2qL)M+ibvv$;TrLlG(`j3+T(IaH! zU|?@%>u6?UjsI7#p1zHfBR3)8-+})7{AZp&KN`Ft!ERQ+mNYEW-d(Wq0ls9J(<+5^Nb4Rytbg2)do z%>|(+AohFTJ~w2|kOp#90yN?AWMQ(|^0@oMIh>KE`TapmS zoFhrmh!76&Z=Ehk>2lu3f7|hZ7S6bME6v1F0RQQN@2-N-^O1Lc5P0uNBQW=;3OH+! zIXlU?T-29uHp}AX~gAYI(?So_}t3GNlpuS2CswFK4w#^(IP*zdLA@H5Y@D z_6Mbyh0yoJl?*^4wZSRV(Ar#QFO>wXZW@b6Mn@&2lLAA)RSc~Y#`u5=iDgghD!EmD zWv?^+_!RKYE}7Iv1c=HwGV}8$5StaC07CIg!2--)mM8lhYQt`GQPtoboU9NpQ*(W~ zB^0=Q9F#1mg+9N&*^Bd|pH}(I-DuMN^9geeZoQ8_4Oxvu`pa9cEa>mr9t3)hof4P# z9mF;V0Cnh|7qSMN!Vek=!2>I+4y*DAc9u^Z#X^LfhxRHVfp83Onnl8%q@`;0h|s)2 zK_S^-%0ZM^Ad6B%k*VUcN~M?<&9iV65gR&Fq9puU@FC0wo1ce;*hy6(j3vh67!S$X zB`jM)nST=osn_mPh$rVVF+Wg1=kEQT2{XShYxgY+GbN4yu_&?497s+z#%GdJ3Qn8d z{%0#pAJqd4#>Z$XqwKaLWE|Gl240CS=xfYpA-oP>S{v0~OlbsIkQns?%rTRbKuN<{ z%GGaOTmL$Xqs@59wT>@g9bONIg?YkXH4&3RtE}fAbbVeIz64C>57V=4Vi6pX9~~}4 z6h!$lq3;Hw3qjbZb%NAR(9fo?LNYnDta|%&K0`)7-ZM?)zGO)sdl!RqB{jY_ZCvp8 zFnMlZXHQ=;bf3ts=)HSKbV)Nhj~Q7%ukY9cfCkrWjz&nT`%7d@$5JcBBBED1s3;CZdqbp#&#S9A^Hq-r^xd*l47z{xufl97l(Ep5Kh$5Kr>T$ji}*(bTTBbb;&tgTvxJ zHzNl_h@3bo&s)8j)ay(7bU9NKa3Hqe6!;T&cYi3*4Kceum#H+5_n+g@6Y4vAHsbkS zNRBogu~sOIH+|0Sz2U3dKcTPtiDc@S6ab3hlwWKN2p|Mt3iEo1EV}63OWHkwUUS)$5t{Z12*H=?;AY zBYac9j^7-ufYxF>G6IYi*42oge2OQ9zt}pnH`SB38)R7eGFcC3C%MsRw$Y)JOcrA% zkRVlS!HlX)HPMhhXLQjVRqYpBMi}4yT-xl87A!wvr(Rda^4zbp-lgl=x(oR-O*nc} zhdt+-i8!eowCMFlVpb|DaL$Lyi@V|<12?iF_rc26+KPR3X9~oYY0YRJq(~?jbs3B> zHyvkfp*I|dd?vaj|Ffe2S6@yCR(&6%Qvmf2k2P7WdTV>fKS{npv!RS=m zEFDwH62?~RIO7f(UM8c}4Q$zOb4Mdmytp|cGr~yKkgWp4nsRbWIo+5m*;tL zXS)Sep*e}Mcud^C00J1cb_+^mb9fqynzH<^S3wyVi;m-CYH7(lZ}*s~QYN!_+-$D^ zwI>LESVk>A#}uGzDsKw#|;Ov>Scy?x$2~vhZx=j1*t$U?f>wcG()=Pmimn5M9ta-fpz%$bPyC zsfN(I1WXD6YBq_TV=S%fGHTNm#=_>WY;JQ}UO(5MQ){#tA0G4q3gHu-2IjF#BOrkX z27$X_&P<6(=%qTeSkkv**SV}Xns&v!wbv;i3BDZio9Gt&cT=a&@Y{J5yYM!9*Hg>H zHtS8t_Ov1u<#mkFr}wt{`wj`)XFj<{g_4lbW1cYka^F+j&;UjNKeo&X8yw&-Hp3yv zX|?4EjrO|F2Pp9vOw?78U4FUL^vHOLzzvacIosEO$ILA*^?}+H622(Bu;QqhlZr7|^Xse=#4H zBZD=XO^0z8D|Wd!F-ea%?v?T4{_;CuH0 za4>i~XRc`Y-s@xl46Seh!;gM6&=SXw61rE%QaGoe zljti)TFoeZFRC*b(1i@&_HpM>gpWQdOh8n|VvAev%qC-RB?Fq? zD3At~FG>rspir8L)Uuuy-)3oSjU^ay4#Qenby?0ix>PR&zz&Ql=16m+`+m=x*clp+ z!P>U;@f7(oqhq-d7)%uury-)+e@vp z1GP}H3k^I6E>)BQ2sscPJJ5M#UjY9fR4!xVYu~^N-pVM#gry)NboqI85wtRFvNh{NN3Fl_3O}}O*aaSxRZ=YKT<&$SRXJ!g!OW0oi>`R`@NlC zswczN=&$tGh8;qhcFtI;l0_~JR|tN%2AaR*9}Y`5V^k)P>r`UHHB>?O*GO$JT9J70 zkUHHtE*Yn`;Z=kOHQ!WNwXw-5!#S!z7hrtfygia3zMwUN^tQ0LT*Su<>VKj`S;zkt^PZC!=}XVBj)S1}>y ztDxK)xVwYpgdC@oWGg^fuC53e_YIym#S~{Za6Ko`JzmZU=2tA5%&dh7=zGF1CR9-j zFGL~H|9m39gLn(g_G{alQCpN@BL53X-~ji%aGZ?q!%;Pm=IWEo&*69QoTj!7bM7B( z2vV5OPNipNFSQ%-IoPp20Yytxj7zejBBgvwh9~?hJz7}_jL)?j893A+zvl83sp=C7 zoQ3^rE7eS71EiM5+|CkERYTO{(qdypHV|2Lba);3tuDx+;2KuUKK8ac!@_1GzHGFS zzRZ~ApkzI4R+Ca8SuZa-fcl5-q3Kb^j}x1Y&#lBtXtiod*ndGjC{XPV^2y7aG^kC5 zf7WXgA9;F`vh!FxN@x&PN^jPa5TNgc4`^wqA#*xg8}m0+s)3`WA1B@A$Je3Yr)0*K z>T)-MQs9bDRFgDHB*~O;wHY<^!dp0sED!Iq#*C#mn@EqSaXR!SUtk@1GRAbS8eVr# zqj5N3kq3Rl*V1H&+B;m>A=~#d6u0Q$)q}?la?V3 z&b`=ZFMqv)-l;Y)_8J~!jscknBM|)->L_L?F`WMXO(SX2Xo&Q>Ftwzk0h%ndVKnsH z%~O%7YxD77YjU_N3Hv=3>F{;ptLvM-Cuz6=2;Sl1u~7B4_{H8YnyJX}t(x#H_@Dh4 zcnN1{bC_S@o@9|%^j22XLdqDkk|sYY=-}*OOg=vbTZ>IVs&p*5cNysZ=p^j1Oh&8+ zuKC>eztMojKyb925LS!bt7kj2B|%|ww1y4RWN-S05$T&)5^B_6NSau(qEO&=K8sS( zwKCPsYLgOOkxFcK9`A2*c_`a46^EhG{TL-u3)R=kC+&Clpj*rFYXD3kC-p~WbN$T} zbFB=((I;E;4^HtF>}g%DvokD4q74ZP-2@Vm(m|QpuobzTVIMCJ0sn!UviGBC>sgNm zg0WxxhYLO_9=pj+=rsVX6+)al2LrfVb=EhU;aXY0zl5}RAbh{vMFs=E8Pk9I2}W`r z81v$N-iUIJRsV3mg*nRhn`T^lLW>)0V;E!{1)m?nNlkbZMA*j!O;Xy zHCS^WT4pDc8a~>5K;Ody99#`l+o51;aw^~j7X%v{d*sY8k`&+dN!pqpk;_t7ZkSH8 zMCkwpCF}y{&^~Qt4*Go*S~sz+4fMLzzaXO`GD|cKD6%})JO&q)ziHrcsybMmKPr@s zL8NMAvLB`r#}k=mFv>Tz>pfU$cx!f5+`h9DIW!~4GZ$n&6q zln#>#BFA018db#HKW-2>;C|zH&iTaI^rX=rwLrpD{jnuuX-H9Y)BWQZEQ<9=dlOso zOEYSUc1HzthYuskv^!{e3XKi*ygw3&kY^>8QG)tyEiEA^hb5TXz-QcEhd+0H;2;mu z4BLv;k@A0jTgKdUB3;eLWNy*KEDN}~{o4!+SJFiLN%E9kvk6zmLs`Tmc0tLEXP5S~;E>9qoqXqg(TK4llDWygC8oSO%K&T9#o1fQ7B$2Z& z5Q);(J!v!z?{N_6892M%4iMP8ewh{wfy?6YdhHDWCm|tue0-!)Z*;wzWVKi<2d&x7 zx7l-_suGUy2!^=!;4F3eMr^-UbM$U1%R4=uUZ0<9DPAQJOFtn}U2*uGcCkzm5fM}9 zbZBX4s&u+}BInM}&sSDfGFWY7vbk1gXAz&v_7QzW5|mK@{gBJ^n{tg6K`42FNVP+F zuX_%AXphE{sAy?vZ8{%R0>EH%8K17Ux0|grBqX3TMWeAH@dB&gnG4YrK&T0Ot}c22 zB&i}Sx$<#U)H)1>^HGryRTU6B6GI5)oDgfzopiy0wL>z!;BmQH&E|^>gSWP}`a|G# z>J1^!UELo}K|_#OSXefkL*TN@*WtAV#z8leTyX>fWea%b!YpyH%^9|}4&B*2nI3Qa zfRbJ}pySAt{A?TtBw3MMzv)V(BRxO>58hDssPxF;^w!w#OH0>!37wPH1ck#Y_0OoG%6Ds>?zGn09$t zuDlI~+;X_8DUs9;5WYr5|= zb!a#7^{lyy7RWO`n!hMQ_uEdKwvFO+cUnsV>`X+xtn?+&6RXy5sjbZZ>&ofljd+(Ls!MG=@f(loFKqx$7jV zHL-k#aK0vyp7LeCpp2XxJWIzNdf2@k8)7Lo&u4nBQLryP{FL$WAM~-pz{0tVL_K)R zx@6*Ot}g4ZMkQt3!0p~$j{-7?f*s6cwK*SOJ7VlkeIK%lnufGHF}c0J z&V8!=3>ZPGO_TY2D1a8R0V~KXYunrolYU)pb!H?cCZ?sW)*DG)b0c(ny|G}B*)D@e zuL0m%8}AOY|GkhX$$bMwf1^zB$=9{g6o-JALcIW;9aWecVY&sw+=zsHtwoq2EENIPw z*EZFzR^1YEARU_&ftXJ>7D zUeU+wA<~>Vz~k!{*0&agDZ=}i7;A}K9b7Ft4tmR5cqpHCF0jM79`!^Xg*HEMg-^V z%d7;uWf3^+l$mb>oCymRx#muw=bLea48DG>O~G49w1uzjy>X~gywk@8TWJfMDh*`2 zbh&n>z4mH#8fa$ ze@zksptlY9-Hw{Hsw6)MJb0G+jhLFhrPcvlU>|j}V+?Lt+{jcfT()RGF#pFfqP=ac zAjD@)j|yqPA(mPKhOpzQsCiaa05BIAxDYM+-upqBVu}0Xxp?Tk1fnYj0{!aQ4uJ`# zZmBQ$UM7NJJOMadFk4apIK20DFSzY^h+&$$yU+}U1Z12Xajc;s{iYInA$=Zctr)#*nl`wjzVY{#)4~3=AuwE}?Ydf|e8Bd*6R^P& zVq#+77nxohgf*lgyB**+dj>*D@b~yxowDLV|3Oh`54QmD=4t4Omk32=4kNsV`TMRn z#C+kg6Scr#xY!f|dg_v>`t0T)zHb@)o(E_Bi8Lk??L=iDN$o_8M$Pb^JO1y3B;8X2lZ-2Vg`PMM^-s=9G%vDW&SOTHM4kFbt#k6sfKu4s+&Q{t> z2M;t|+tSUIJDaWml8x*4HFn0;X{7W&M7t92cYnCelV9Cn#mxEtK!PNwZx}$S>>y|G z4{G3J7X7y3GYOaGTWgK(|A8gKM7n!U;Smu!jogi%|AK|mslV3w*<8k{nyL!xU-;2s z`d7VY@vHE<;CRVD>QVeoBYj6(gen|_%{KqVCAmoWy7kL`F_=m~38j$#@dOCvto}I^ zE|S02G3UeO|G`J$JrRPw*|nW)=Vqh-naZJ{@1>J=7mLp<^VHawJ0>z0H+POm=GZ?x z0U983&7HnQ~c zxPExB+Gwp)rAC`+BlgDgqF`FM05CELEri&H; zKgqq`7D-Nw7u{IgO~|$MdtrV=eSWm$mynOJD_hpi$;bP<5Fz4=e~z$Pm5PR{YHCi7 z&+!a_DZY)3jZ7vU1x=7__VSJ*B|^wFmqDD#y5!^=%t8gwkQ~}ya0mH!BhZ{u;U#%8 znh3^H>WBK56?DDC;5nF^8Go+b@%TRAW{w@YzG)!pWolv$f)pD z)ZajzezxF|Uk9nV=HY45>2$%A=%KlA6&39W<5Ps(=o(lej3+53``WpS-}+0lm-+(?uN!vmzF# ze|GF*{sM*9;qBJ+RvVzSuWEeEz!ER-$AEz;l-K11ivBih-zbxwJ~jp*ECH=|xi9fh zaowvS%HYJ|d*CG|IYWS>Qt?qYLYuENPLSN1o42)`z^cV6s zpU>*kpc^yL(b3VSM5t1Oh6ef>APcn;>tqteL!@}><2vxmkfHD1UGD>7v3Ub-YJ!kY zhYxGE^+bv%kkV*0M;ZiZ&avvI0AGL}S!$x86WFjWpIuR#&sg*K6cUZ#4wrCl?3q^NX0ZUG43KK4=JT5DAgJM+e5!9c|6;#x$v>F21Z#I7f zJ10vHlGB*a*F6(UM^rnXzhne`+L(igFCvIrnx{rG3bgzLX{^&=EpmJOF}paPZ(j{% zJ4T*>k6MidmmFU7Go0^GxW|wyaWxC26>TXv5D!lX zhX(gifurHyA2nmpXxW~vaI`jB!hb;Jzqa3Kklw!^fIv9ncAHF2z&)I^sYO7wX_6A& zKwIZcPH-oLN|FsWbhGKiIom(YR=7eF*`00FxFEZolxd0LyHV?ZybN)N1TyO;?nGo2 zFoi`3k&H~iJ+XT`gIb9nAZWP$V;N3L0(6H4UaC64+QuDJkWx(Pti~|0`L!sAy z4Pp2?m+5u0-UNHHH~z2Qd3^|iIDW4xHa zSkLti@MxCX;&H+9G2lO6ZqCYySOm|pO)K`3(&i&_0Ay-~E!apzEP3V%oX^ae2A-!P z9#w+gLm=RI;SvIFvlm8a9{zpnq(0w&RHs6lbHu*l^KERey1#H?v>Qzk2f~yyKQzx$ z1^sX$w0-U@QJ_-M)l&*xm(NRkvU41_?N~UEhAy{Q-$19@2947z>P0A;lJiaE@zyq)*i6tTC}*4*)KW^+MO*Uo=GStNj>E_`InUn2R5l~ zvDjV%;sia~_dCGj)??N|p5Ar>@}eSYF1Z~-N}xn~r%*C>X7M~{8tP7ux7cXdy2s;o z?9-jF*z6BNSv?x>A>bp(vXN3tklI>4i`x!F1}9*=&?fs~U1p>CdO z%C?3ZMp-1CT4MD%lhH7=OSb=ppL3AA*rAoy(i0EKw0aQJfL1B2SFeV1M(Wg_1OR%A z-dMn1sMRQOcV-!>c0=EG0I$|wZZZ#A7^(W$xtz?3YZmj5PczDhGPz3z5n=RD4@~6U zjmLqn1w{Nq;y@t~Sm6s!PEUc%5oRkBM74kkKO=4@R5D0a0zwGn9Zwyg&A)nN!@Gb9 zMeamn9gYca&IWW^Gb3TAu>&-hkk-J)PCITf=;) z_iLaR#3asEnck$r!sRAKR&lp8#|M?H4ckWj??QSye-voKLf1!h?h!HJkru@}_gVq- zwW9u$#?rGVrwfZ^e@LCvy<=VXzF=Zn{=7W5aE~xMNKyC!c%K3qmp(8Oj#mGO-yI%r zezDP68f**XUa@h&>m~Q|Z}mc;5`_~~dr(3000E%4k5}V!yB$DQu-~{Va;+EVLsO4^ z|J)p!ia`|1QAcHbc1lLaemU|>?^NrD=!GT;%LvoV`X5aOCgWDZ3B+8K@0(2M8w;s2 z*FC=mS&m4pe`-KUkR6~TuvVBp`^qbWV z8AMG%+CxE-r5q6caqObzok{{lq($JyVzoYSKq9=hts)vZj31a?fIbb1_vX%F%4c%W zuK4Zs+JKSq1bd=UQ47I5zig<5OpO7Rls-3S;&9Z_sv2U6u#QtFJc#M6Gw^Be?62r} zbM4PcLxS``dg;1^tp;V5nD+QrO zsH=a-IfcP#yuHKa?F5>&QSg0pCM|GfVu-u*T=EY0B+KZr_KaewXZI)d#)@2nwt=YQ zrKJF8&SU-F#4}2i0W>cQmIwyc;CYdo%Ozoo`&9FL+{me(PcLAPqf#G8C?7t%@X1@r z^jgpJ=$83 z_*l^T88%}5DL(Q-G?__loCxDCX6w=2CghB^6*IfVOo9qMF?dI(_ufKQaOMOZ-o_IY z_d9T248*!(Ild5{JisfUWd-8uu;-7m>~6Z>nX$2})txElQ-gQvN)=@#G&cZ1B(X|= zP$e=gdUsbRHQILkKyVaacB0pMD-^F-9kGb!>~9Bpc`nI!FSRDAHZvB4ipKPZst}6k zp*Cht+TkyB%y#9GQK@$M)XvveXm%a@+N8Rhwd?yjL>Wyu({NIwu2=~iKGPo`h{;bZ z1wu_qPuFe|kq#t(&Ql5JmJ`S!)+oB(*!yX`;SL<~fo5eiS9`f{zl^inJf5VqdrkXI zb_2?9952RgVu7(F4h~rApySc&n_{hiF<7*u_uAb5Zhz?c+y?L+?SGghHUQ3;hEg#5)Z&UZFJ42yo*MDZ(o%qf#XxB8W z4%oxBG|aBMK1OMCGM_K{#xOMPua!E%TH%hLpx|v|$MtP^Z2b1lsR+=xe z+TO|Z0%zI33?;5|?@~k0M4INe&8+qRUF%_iv=8dL(t4)*Zb?eV)#3ckhxCGwZ%`5& zOb_7>ciQM7I`P{0V<-MOxO;h010=(P3&y zc(}!Eey9U&DVQ^_AG?r-mbQu_ITXg^@_PGZM1;CruXP;AvIp3l0jm-%?T}FPCtb8$ zXsFwz&UX%2p*XUJhK4H-)F*tLkZ%52}dYLIO<;L3V;QSrIEG)h^LDv87uQ4R@LBOyQ3l^xA#pAOrRo> zY$Pd;%i{yp6Pu0D1o;rq1YJU0h#q4DqwBFrMkLw(4k4~rH!LtnhtA*(z}<2#M=_5v z@B1p*!wLz^iJ&jYEv~9cEue=ommqFUPEEt$CHzxnnhWtF*)*1~;E`rGKe#Gr&R>J# zHx>iHnqQT!Zru``2i-S;FetI|B@E8q&qh`bi50fbHLod9Gm>=f#Fr_w1Emmq5X=yU z;88hSg{p-38GfxgZxdxLUBdjjf4Y0eTP-7l1_J7lB@%@xox}4HLD!YaU=SP=13wGH z^v1R=~u5cW|8hO=O4_A$b}POX%-4K<%K8?9x`uhu@|*@7RoyC(auj z0G8-0`_m*1PsXy5k38?j3w);QujtQzM=R$b60e*pm1XHAK*eRoFtZF?{UM~x4_u=r ztbt(Z_4DKyt;GXZbfahyj^aJNqaotLj}OLgUtZiR2dhTa3w{Z~=Y-hAFlV8i?8x9F z*vagY<#^hk%oWA(8pHLc9*v6^^t7*Cmv2e{e5lydV}#eHYBZye=}TwQCuWUP_=&A% z8XZB6F`A&sjmaciz-(J$e$yECAlVyd9kJoPH|p~8h#mLHA`~F0AbQcrsz8p6W>Cjy zCJH;d^Pq&9@iht4Hs5Aapzz!8etx+hrb(f*m;|$MjHm}-1|jS-QSJ8TP5}YiOVd>Y zh%Pab>@T>+1`f+)cM##r6aVX7lkn$;yWzO{e13-}w6wJJ%mdt_(!heW!PVi6MeZJn zgzSSbunl*iEQx`BIUw=`)g(mRIo7w;i(ci;J+9 zooQb~%0Q~0_&M5fkBy{nt&ZMmo@zW>CT%OMeJP`=k*b`d^`#;PA|BkVWNWbv~@S~EuUj-7;9}DdJY-v{nHp?df0IDMQ z?D_u2Uc>myy*PlB7cMi@fEE3X1$`4Ld`Wz{nHd<$`EUzj|HJE0Ldex9DyxUDiT@Kb zDhK`!8YzXS6gvGUf}{-kO&66*NXm15N9?{eIPvuyjGDRft2mV+SN)e}Qu#|u&3|3t zKGVR%f0_tWT6BI%w(oQk<=~Ef&GD7LVc-L3Wi{6Wh=zv bzaUIVhHxHlwNKE@D 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 HEvrKOV7T9rxy*vJ=*-~34WUR}l}O{L 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 literal 27047 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 BFlqn* From d1ae3c899f9284beba43ae2c82c42a5feeabdda1 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 2 Aug 2012 06:54:19 -0400 Subject: [PATCH 066/367] [1.4.X] Fixed #16941 - Clarified naturaltime output when the time is more than a day old. Thanks antoviaque for the patch. Backport of 07e10fbe9f from master --- docs/ref/contrib/humanize.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/ref/contrib/humanize.txt b/docs/ref/contrib/humanize.txt index cdc3009a51f5..57978288b1dc 100644 --- a/docs/ref/contrib/humanize.txt +++ b/docs/ref/contrib/humanize.txt @@ -103,9 +103,9 @@ naturaltime .. versionadded:: 1.4 For datetime values, returns a string representing how many seconds, -minutes or hours ago it was -- falling back to a longer date format if the -value is more than a day old. In case the datetime value is in the future -the return value will automatically use an appropriate phrase. +minutes or hours ago it was -- falling back to the :tfilter:`timesince` +format if the value is more than a day old. In case the datetime value is in +the future the return value will automatically use an appropriate phrase. Examples (when 'now' is 17 Feb 2007 16:30:00): @@ -115,13 +115,14 @@ Examples (when 'now' is 17 Feb 2007 16:30:00): * ``17 Feb 2007 16:25:35`` becomes ``4 minutes ago``. * ``17 Feb 2007 15:30:29`` becomes ``an hour ago``. * ``17 Feb 2007 13:31:29`` becomes ``2 hours ago``. -* ``16 Feb 2007 13:31:29`` becomes ``1 day ago``. +* ``16 Feb 2007 13:31:29`` becomes ``1 day, 3 hours ago``. * ``17 Feb 2007 16:30:30`` becomes ``29 seconds from now``. * ``17 Feb 2007 16:31:00`` becomes ``a minute from now``. * ``17 Feb 2007 16:34:35`` becomes ``4 minutes from now``. * ``17 Feb 2007 16:30:29`` becomes ``an hour from now``. * ``17 Feb 2007 18:31:29`` becomes ``2 hours from now``. * ``18 Feb 2007 16:31:29`` becomes ``1 day from now``. +* ``26 Feb 2007 18:31:29`` becomes ``1 week, 2 days from now``. .. templatefilter:: ordinal From e9f458133c7e2357e66b3e0b97f7b454ae34a684 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 2 Aug 2012 19:21:48 -0400 Subject: [PATCH 067/367] [1.4.X] Fixed #17704 - Updated the StackedInline section in Tutorial 2; thanks xbito for the draft patch. Backport of 2a16eb0792 from master --- docs/intro/_images/admin15t.png | Bin 0 -> 22805 bytes docs/intro/tutorial02.txt | 8 ++++++++ 2 files changed, 8 insertions(+) create mode 100644 docs/intro/_images/admin15t.png diff --git a/docs/intro/_images/admin15t.png b/docs/intro/_images/admin15t.png new file mode 100644 index 0000000000000000000000000000000000000000..999d0519fb59c8cb64cc41d9912a9b5886feec86 GIT binary patch literal 22805 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 Date: Fri, 3 Aug 2012 05:17:52 -0400 Subject: [PATCH 068/367] [1.4.X] Fixed #13904 - Documented how to avoid garbage collection messages in GIS. Thanks Claude Peroz for the patch. Backport of 083a3a4e39 from master --- docs/ref/contrib/gis/geos.txt | 11 +++++++++++ docs/ref/contrib/gis/install.txt | 2 ++ 2 files changed, 13 insertions(+) diff --git a/docs/ref/contrib/gis/geos.txt b/docs/ref/contrib/gis/geos.txt index a82ac87acf8c..24362f3b701d 100644 --- a/docs/ref/contrib/gis/geos.txt +++ b/docs/ref/contrib/gis/geos.txt @@ -75,6 +75,17 @@ return a :class:`GEOSGeometry` object from an input string or a file:: >>> pnt = fromfile('/path/to/pnt.wkt') >>> pnt = fromfile(open('/path/to/pnt.wkt')) +.. _geos-exceptions-in-logfile: + +.. admonition:: My logs are filled with GEOS-related errors + + You find many ``TypeError`` or ``AttributeError`` exceptions filling your + Web server's log files. This generally means that you are creating GEOS + objects at the top level of some of your Python modules. Then, due to a race + condition in the garbage collector, your module is garbage collected before + the GEOS object. To prevent this, create :class:`GEOSGeometry` objects + inside the local scope of your functions/methods. + Geometries are Pythonic ----------------------- :class:`GEOSGeometry` objects are 'Pythonic', in other words components may diff --git a/docs/ref/contrib/gis/install.txt b/docs/ref/contrib/gis/install.txt index 9cb945f76a21..f6cd1346cb3f 100644 --- a/docs/ref/contrib/gis/install.txt +++ b/docs/ref/contrib/gis/install.txt @@ -191,6 +191,8 @@ GEOS C library. For example: The setting must be the *full* path to the **C** shared library; in other words you want to use ``libgeos_c.so``, not ``libgeos.so``. +See also :ref:`My logs are filled with GEOS-related errors `. + .. _proj4: PROJ.4 From 1d280026c3797a23075739f28bb238972afad317 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 3 Aug 2012 16:06:41 -0400 Subject: [PATCH 069/367] [1.4.X] Fixed #15932 - Documented how to supress multiple reverse relations to the same model. Thanks Claude Paroz for the patch. Backport of b496be331c from master --- docs/ref/models/fields.txt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 5062e90ebe97..6590a413d7d6 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1002,9 +1002,10 @@ define the details of how the relation works. `; and when you do so :ref:`some special syntax ` is available. - If you'd prefer Django didn't create a backwards relation, set ``related_name`` - to ``'+'``. For example, this will ensure that the ``User`` model won't get a - backwards relation to this model:: + If you'd prefer Django not to create a backwards relation, set + ``related_name`` to ``'+'`` or end it with ``'+'``. For example, this will + ensure that the ``User`` model won't have a backwards relation to this + model:: user = models.ForeignKey(User, related_name='+') @@ -1096,6 +1097,13 @@ that control how the relationship functions. Same as :attr:`ForeignKey.related_name`. + If you have more than one ``ManyToManyField`` pointing to the same model + and want to suppress the backwards relations, set each ``related_name`` + to a unique value ending with ``'+'``:: + + users = models.ManyToManyField(User, related_name='u+') + referents = models.ManyToManyField(User, related_name='ref+') + .. attribute:: ManyToManyField.limit_choices_to Same as :attr:`ForeignKey.limit_choices_to`. From 49f9bb271dfeecca431398363e6683780ebcc70a Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sat, 4 Aug 2012 17:38:18 +0200 Subject: [PATCH 070/367] [1.4.x] Fixed #18713 -- Fixed custom comment app name in comments docs Thanks Pratyush for the report. --- docs/ref/contrib/comments/custom.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/ref/contrib/comments/custom.txt b/docs/ref/contrib/comments/custom.txt index 5007ddff6928..0ef37a9a0bbc 100644 --- a/docs/ref/contrib/comments/custom.txt +++ b/docs/ref/contrib/comments/custom.txt @@ -51,9 +51,9 @@ To make this kind of customization, we'll need to do three things: custom :setting:`COMMENTS_APP`. So, carrying on the example above, we're dealing with a typical app structure in -the ``my_custom_app`` directory:: +the ``my_comment_app`` directory:: - my_custom_app/ + my_comment_app/ __init__.py models.py forms.py @@ -98,11 +98,11 @@ Django provides a couple of "helper" classes to make writing certain types of custom comment forms easier; see :mod:`django.contrib.comments.forms` for more. -Finally, we'll define a couple of methods in ``my_custom_app/__init__.py`` to +Finally, we'll define a couple of methods in ``my_comment_app/__init__.py`` to point Django at these classes we've created:: - from my_comments_app.models import CommentWithTitle - from my_comments_app.forms import CommentFormWithTitle + from my_comment_app.models import CommentWithTitle + from my_comment_app.forms import CommentFormWithTitle def get_model(): return CommentWithTitle From c54034a2ad9e0ebf918530fa4b8f11e4ea0f489e Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 4 Aug 2012 15:24:40 -0400 Subject: [PATCH 071/367] [1.4.X] Fixed #16980 - Misc updates to the auth docs. Thanks Preston Holmes for the patch. Backport of 865ff32b84 from master --- docs/topics/auth.txt | 65 +++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/topics/auth.txt b/docs/topics/auth.txt index ba427d5b4d0f..1b0b9bd2a08b 100644 --- a/docs/topics/auth.txt +++ b/docs/topics/auth.txt @@ -101,12 +101,13 @@ Fields This doesn't necessarily control whether or not the user can log in. Authentication backends aren't required to check for the ``is_active`` - flag, so if you want to reject a login based on ``is_active`` being - ``False``, it's up to you to check that in your own login view. - However, the :class:`~django.contrib.auth.forms.AuthenticationForm` - used by the :func:`~django.contrib.auth.views.login` view *does* - perform this check, as do the permission-checking methods such as - :meth:`~models.User.has_perm` and the authentication in the Django + flag, and the default backends do not. If you want to reject a login + based on ``is_active`` being ``False``, it's up to you to check that in + your own login view or a custom authentication backend. However, the + :class:`~django.contrib.auth.forms.AuthenticationForm` used by the + :func:`~django.contrib.auth.views.login` view (which is the default) + *does* perform this check, as do the permission-checking methods such + as :meth:`~models.User.has_perm` and the authentication in the Django admin. All of those functions/methods will return ``False`` for inactive users. @@ -1781,7 +1782,11 @@ By default, :setting:`AUTHENTICATION_BACKENDS` is set to:: ('django.contrib.auth.backends.ModelBackend',) -That's the basic authentication scheme that checks the Django users database. +That's the basic authentication backend that checks the Django users database +and queries the builtin permissions. It does not provide protection against +brute force attacks via any rate limiting mechanism. You may either implement +your own rate limiting mechanism in a custom auth backend, or use the +mechanisms provided by most Web servers. The order of :setting:`AUTHENTICATION_BACKENDS` matters, so if the same username and password is valid in multiple backends, Django will stop @@ -1801,8 +1806,9 @@ processing at the first positive match. Writing an authentication backend --------------------------------- -An authentication backend is a class that implements two methods: -``get_user(user_id)`` and ``authenticate(**credentials)``. +An authentication backend is a class that implements two required methods: +``get_user(user_id)`` and ``authenticate(**credentials)``, as well as a set of +optional permission related :ref:`authorization methods `. The ``get_user`` method takes a ``user_id`` -- which could be a username, database ID or whatever -- and returns a ``User`` object. @@ -1873,6 +1879,8 @@ object the first time a user authenticates:: except User.DoesNotExist: return None +.. _authorization_methods: + Handling authorization in custom backends ----------------------------------------- @@ -1903,13 +1911,16 @@ fairly simply:: return False This gives full permissions to the user granted access in the above example. -Notice that the backend auth functions all take the user object as an argument, -and they also accept the same arguments given to the associated -:class:`django.contrib.auth.models.User` functions. +Notice that in addition to the same arguments given to the associated +:class:`django.contrib.auth.models.User` functions, the backend auth functions +all take the user object, which may be an anonymous user, as an argument. -A full authorization implementation can be found in -`django/contrib/auth/backends.py`_, which is the default backend and queries -the ``auth_permission`` table most of the time. +A full authorization implementation can be found in the ``ModelBackend`` class +in `django/contrib/auth/backends.py`_, which is the default backend and queries +the ``auth_permission`` table most of the time. If you wish to provide +custom behavior for only part of the backend API, you can take advantage of +Python inheritence and subclass ``ModelBackend`` instead of implementing the +complete API in a custom backend. .. _django/contrib/auth/backends.py: https://code.djangoproject.com/browser/django/trunk/django/contrib/auth/backends.py @@ -1927,25 +1938,27 @@ authorize anonymous users to browse most of the site, and many allow anonymous posting of comments etc. Django's permission framework does not have a place to store permissions for -anonymous users. However, it has a foundation that allows custom authentication -backends to specify authorization for anonymous users. This is especially useful -for the authors of re-usable apps, who can delegate all questions of authorization -to the auth backend, rather than needing settings, for example, to control -anonymous access. +anonymous users. However, the user object passed to an authentication backend +may be an :class:`django.contrib.auth.models.AnonymousUser` object, allowing +the backend to specify custom authorization behavior for anonymous users. This +is especially useful for the authors of re-usable apps, who can delegate all +questions of authorization to the auth backend, rather than needing settings, +for example, to control anonymous access. +.. _inactive_auth: Authorization for inactive users ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 1.3 +.. versionchanged:: 1.3 An inactive user is a one that is authenticated but has its attribute ``is_active`` set to ``False``. However this does not mean they are not authorized to do anything. For example they are allowed to activate their account. -The support for anonymous users in the permission system allows for -anonymous users to have permissions to do something while inactive +The support for anonymous users in the permission system allows for a scenario +where anonymous users have permissions to do something while inactive authenticated users do not. To enable this on your own backend, you must set the class attribute @@ -1960,9 +1973,11 @@ passed to the authorization methods. Handling object permissions ---------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~ Django's permission framework has a foundation for object permissions, though there is no implementation for it in the core. That means that checking for object permissions will always return ``False`` or an empty list (depending on -the check performed). +the check performed). An authentication backend will receive the keyword +parameters ``obj`` and ``user_obj`` for each object related authorization +method and can return the object level permission as appropriate. From df8a2bf4cbc962e52f3d8b440eaeeea2a984bbfc Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 5 Aug 2012 10:21:14 -0700 Subject: [PATCH 072/367] [1.4.x] Merge pull request #233 from rafikdraoui/modeladmin-doc Updated example of customized ModelAdmin in documentation for 1.4 Backport of a04f68b15d8466d0d34f6df1009668cf60dad206 from master. --- docs/ref/contrib/admin/index.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index ba8f1acbd8be..7bee6b56f8a0 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -1281,11 +1281,11 @@ provided some extra mapping data that would not otherwise be available:: # ... pass - def change_view(self, request, object_id, extra_context=None): + def change_view(self, request, object_id, form_url='', extra_context=None): extra_context = extra_context or {} extra_context['osm_data'] = self.get_osm_info() return super(MyModelAdmin, self).change_view(request, object_id, - extra_context=extra_context) + form_url, extra_context=extra_context) .. versionadded:: 1.4 From 6536f7597b4cbccc53846d099fbc7a6b13929ab7 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 6 Aug 2012 16:15:09 -0400 Subject: [PATCH 073/367] [1.4.X] Fixed #17053 - Added a note about USE_THOUSAND_SEPARATOR setting to localizations docs. Thanks shelldweller for the draft patch. Backport of 4f3a6b853a from master --- docs/topics/i18n/formatting.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/topics/i18n/formatting.txt b/docs/topics/i18n/formatting.txt index a459b950420d..a272626ad4c5 100644 --- a/docs/topics/i18n/formatting.txt +++ b/docs/topics/i18n/formatting.txt @@ -23,7 +23,10 @@ necessary to set :setting:`USE_L10N = True ` in your settings file. The default :file:`settings.py` file created by :djadmin:`django-admin.py startproject ` includes :setting:`USE_L10N = True ` - for convenience. + for convenience. Note, however, that to enable number formatting with + thousand separators it is necessary to set :setting:`USE_THOUSAND_SEPARATOR + = True ` in your settings file. Alternatively, you + could use :tfilter:`intcomma` to format numbers in your template. .. note:: From fba0149e1604ad4ab3745abff8bba0d38bbfe321 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 10 Aug 2012 16:19:20 -0400 Subject: [PATCH 074/367] [1.4.X] Fixed #17016 - Added examples for file uploads in views. Thanks Tim Saylor for the draft patch and Aymeric Augustin and Claude Paroz for feedback. Backport of eff6ba2f64 from master --- AUTHORS | 1 + docs/topics/http/file-uploads.txt | 44 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/AUTHORS b/AUTHORS index 0af0c7961e0f..3d4ec5244585 100644 --- a/AUTHORS +++ b/AUTHORS @@ -453,6 +453,7 @@ answer newbie questions, and generally made Django that much better: Vinay Sajip Bartolome Sanchez Salado Kadesarin Sanjek + Tim Saylor Massimo Scamarcia Paulo Scardine David Schein diff --git a/docs/topics/http/file-uploads.txt b/docs/topics/http/file-uploads.txt index 1c1ef776bb03..8865e23936fc 100644 --- a/docs/topics/http/file-uploads.txt +++ b/docs/topics/http/file-uploads.txt @@ -179,6 +179,50 @@ Three settings control Django's file upload behavior: Which means "try to upload to memory first, then fall back to temporary files." +Handling uploaded files with a model +------------------------------------ + +If you're saving a file on a :class:`~django.db.models.Model` with a +:class:`~django.db.models.FileField`, using a :class:`~django.forms.ModelForm` +makes this process much easier. The file object will be saved when calling +``form.save()``:: + + from django.http import HttpResponseRedirect + from django.shortcuts import render + from .forms import ModelFormWithFileField + + def upload_file(request): + if request.method == 'POST': + form = ModelFormWithFileField(request.POST, request.FILES) + if form.is_valid(): + # file is saved + form.save() + return HttpResponseRedirect('/success/url/') + else: + form = ModelFormWithFileField() + return render('upload.html', {'form': form}) + +If you are constructing an object manually, you can simply assign the file +object from :attr:`request.FILES ` to the file +field in the model:: + + from django.http import HttpResponseRedirect + from django.shortcuts import render + from .forms import UploadFileForm + from .models import ModelWithFileField + + def upload_file(request): + if request.method == 'POST': + form = UploadFileForm(request.POST, request.FILES) + if form.is_valid(): + instance = ModelWithFileField(file_field=request.FILES['file']) + instance.save() + return HttpResponseRedirect('/success/url/') + else: + form = UploadFileForm() + return render('upload.html', {'form': form}) + + ``UploadedFile`` objects ======================== From 3264894ee02f7099272619fa4f6072d4d2e95eb6 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 10 Aug 2012 16:12:45 -0400 Subject: [PATCH 075/367] [1.4.X] Fixed #17680 - Clarified when logging is configured. Backport of cb38fd9632 from master --- docs/topics/logging.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/topics/logging.txt b/docs/topics/logging.txt index aa2afba76044..0426ae3d17bc 100644 --- a/docs/topics/logging.txt +++ b/docs/topics/logging.txt @@ -218,10 +218,11 @@ handlers, filters and formatters that you want in your logging setup, and the log levels and other properties that you want those components to have. -Logging is configured immediately after settings have been loaded. -Since the loading of settings is one of the first things that Django -does, you can be certain that loggers are always ready for use in your -project code. +Logging is configured as soon as settings have been loaded +(either manually using :func:`~django.conf.settings.configure` or when at least +one setting is accessed). Since the loading of settings is one of the first +things that Django does, you can be certain that loggers are always ready for +use in your project code. .. _dictConfig format: http://docs.python.org/library/logging.config.html#configuration-dictionary-schema From 57d9ccc4aaef0420f6ba60a26e6af4e83b803ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Hertzog?= Date: Thu, 16 Aug 2012 20:51:47 +0200 Subject: [PATCH 076/367] [1.4.x] Fixed #18239 -- Subclassed HTMLParser only for selected Python versions Only Python versions affected by http://bugs.python.org/issue670664 should patch HTMLParser. --- django/utils/html_parser.py | 183 +++++++++++++++++++----------------- 1 file changed, 97 insertions(+), 86 deletions(-) diff --git a/django/utils/html_parser.py b/django/utils/html_parser.py index b28005705e8a..444946151d0d 100644 --- a/django/utils/html_parser.py +++ b/django/utils/html_parser.py @@ -1,98 +1,109 @@ import HTMLParser as _HTMLParser import re +import sys +current_version = sys.version_info -class HTMLParser(_HTMLParser.HTMLParser): - """ - Patched version of stdlib's HTMLParser with patch from: - http://bugs.python.org/issue670664 - """ - def __init__(self): - _HTMLParser.HTMLParser.__init__(self) - self.cdata_tag = None +use_workaround = ( + (current_version < (2, 6, 8)) or + (current_version >= (2, 7) and current_version < (2, 7, 3)) or + (current_version >= (3, 0) and current_version < (3, 2, 3)) +) - def set_cdata_mode(self, tag): - try: - self.interesting = _HTMLParser.interesting_cdata - except AttributeError: - self.interesting = re.compile(r'' % tag.lower(), re.I) - self.cdata_tag = tag.lower() +if not use_workaround: + HTMLParser = _HTMLParser.HTMLParser +else: + class HTMLParser(_HTMLParser.HTMLParser): + """ + Patched version of stdlib's HTMLParser with patch from: + http://bugs.python.org/issue670664 + """ + def __init__(self): + _HTMLParser.HTMLParser.__init__(self) + self.cdata_tag = None - def clear_cdata_mode(self): - self.interesting = _HTMLParser.interesting_normal - self.cdata_tag = None + def set_cdata_mode(self, tag): + try: + self.interesting = _HTMLParser.interesting_cdata + except AttributeError: + self.interesting = re.compile(r'' % tag.lower(), re.I) + self.cdata_tag = tag.lower() - # Internal -- handle starttag, return end or -1 if not terminated - def parse_starttag(self, i): - self.__starttag_text = None - endpos = self.check_for_whole_start_tag(i) - if endpos < 0: - return endpos - rawdata = self.rawdata - self.__starttag_text = rawdata[i:endpos] + def clear_cdata_mode(self): + self.interesting = _HTMLParser.interesting_normal + self.cdata_tag = None + + # Internal -- handle starttag, return end or -1 if not terminated + def parse_starttag(self, i): + self.__starttag_text = None + endpos = self.check_for_whole_start_tag(i) + if endpos < 0: + return endpos + rawdata = self.rawdata + self.__starttag_text = rawdata[i:endpos] - # Now parse the data between i+1 and j into a tag and attrs - attrs = [] - match = _HTMLParser.tagfind.match(rawdata, i + 1) - assert match, 'unexpected call to parse_starttag()' - k = match.end() - self.lasttag = tag = rawdata[i + 1:k].lower() + # Now parse the data between i+1 and j into a tag and attrs + attrs = [] + match = _HTMLParser.tagfind.match(rawdata, i + 1) + assert match, 'unexpected call to parse_starttag()' + k = match.end() + self.lasttag = tag = rawdata[i + 1:k].lower() - while k < endpos: - m = _HTMLParser.attrfind.match(rawdata, k) - if not m: - break - attrname, rest, attrvalue = m.group(1, 2, 3) - if not rest: - attrvalue = None - elif attrvalue[:1] == '\'' == attrvalue[-1:] or \ - attrvalue[:1] == '"' == attrvalue[-1:]: - attrvalue = attrvalue[1:-1] - attrvalue = self.unescape(attrvalue) - attrs.append((attrname.lower(), attrvalue)) - k = m.end() + while k < endpos: + m = _HTMLParser.attrfind.match(rawdata, k) + if not m: + break + attrname, rest, attrvalue = m.group(1, 2, 3) + if not rest: + attrvalue = None + elif attrvalue[:1] == '\'' == attrvalue[-1:] or \ + attrvalue[:1] == '"' == attrvalue[-1:]: + attrvalue = attrvalue[1:-1] + attrvalue = self.unescape(attrvalue) + attrs.append((attrname.lower(), attrvalue)) + k = m.end() - end = rawdata[k:endpos].strip() - if end not in (">", "/>"): - lineno, offset = self.getpos() - if "\n" in self.__starttag_text: - lineno = lineno + self.__starttag_text.count("\n") - offset = len(self.__starttag_text) \ - - self.__starttag_text.rfind("\n") + end = rawdata[k:endpos].strip() + if end not in (">", "/>"): + lineno, offset = self.getpos() + if "\n" in self.__starttag_text: + lineno = lineno + self.__starttag_text.count("\n") + offset = len(self.__starttag_text) \ + - self.__starttag_text.rfind("\n") + else: + offset = offset + len(self.__starttag_text) + self.error("junk characters in start tag: %r" + % (rawdata[k:endpos][:20],)) + if end.endswith('/>'): + # XHTML-style empty tag: + self.handle_startendtag(tag, attrs) else: - offset = offset + len(self.__starttag_text) - self.error("junk characters in start tag: %r" - % (rawdata[k:endpos][:20],)) - if end.endswith('/>'): - # XHTML-style empty tag: - self.handle_startendtag(tag, attrs) - else: - self.handle_starttag(tag, attrs) - if tag in self.CDATA_CONTENT_ELEMENTS: - self.set_cdata_mode(tag) # <--------------------------- Changed - return endpos + self.handle_starttag(tag, attrs) + if tag in self.CDATA_CONTENT_ELEMENTS: + self.set_cdata_mode(tag) # <--------------------------- Changed + return endpos - # Internal -- parse endtag, return end or -1 if incomplete - def parse_endtag(self, i): - rawdata = self.rawdata - assert rawdata[i:i + 2] == " - if not match: - return -1 - j = match.end() - match = _HTMLParser.endtagfind.match(rawdata, i) # - if not match: - if self.cdata_tag is not None: # *** add *** - self.handle_data(rawdata[i:j]) # *** add *** - return j # *** add *** - self.error("bad end tag: %r" % (rawdata[i:j],)) - # --- changed start --------------------------------------------------- - tag = match.group(1).strip() - if self.cdata_tag is not None: - if tag.lower() != self.cdata_tag: - self.handle_data(rawdata[i:j]) - return j - # --- changed end ----------------------------------------------------- - self.handle_endtag(tag.lower()) - self.clear_cdata_mode() - return j + # Internal -- parse endtag, return end or -1 if incomplete + def parse_endtag(self, i): + rawdata = self.rawdata + assert rawdata[i:i + 2] == " + if not match: + return -1 + j = match.end() + match = _HTMLParser.endtagfind.match(rawdata, i) # + if not match: + if self.cdata_tag is not None: # *** add *** + self.handle_data(rawdata[i:j]) # *** add *** + return j # *** add *** + self.error("bad end tag: %r" % (rawdata[i:j],)) + # --- changed start --------------------------------------------------- + tag = match.group(1).strip() + if self.cdata_tag is not None: + if tag.lower() != self.cdata_tag: + self.handle_data(rawdata[i:j]) + return j + # --- changed end ----------------------------------------------------- + self.handle_endtag(tag.lower()) + self.clear_cdata_mode() + return j From 01b02317172a5030b77a09aada11657c00398416 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 16 Aug 2012 16:05:41 -0400 Subject: [PATCH 077/367] [1.4.X] Fixed #18223 - Corrected default transaction behavior in postgresql docs. Thanks philipn for the report and mateusgondim for the patch. Backport of 2079b730f1 from master --- docs/ref/databases.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index 5580d658165e..45c3e6d3c4b4 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -56,10 +56,10 @@ will do some additional queries to set these parameters. Transaction handling --------------------- -:doc:`By default `, Django starts a transaction when a -database connection is first used and commits the result at the end of the -request/response handling. The PostgreSQL backends normally operate the same -as any other Django backend in this respect. +:doc:`By default `, Django runs with an open +transaction which it commits automatically when any built-in, data-altering +model function is called. The PostgreSQL backends normally operate the same as +any other Django backend in this respect. .. _postgresql-autocommit-mode: From e4b7e7d86deb1303997d6c3b893c76ead2d6d46a Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 16 Aug 2012 16:13:45 -0400 Subject: [PATCH 078/367] [1.4.X] Fixed #17183 - Added a note regarding LocaleMiddleware at the top of the i18n docs. Thanks krzysiumed for the patch. Backport of b1f18e95a5 from master --- docs/topics/i18n/translation.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/topics/i18n/translation.txt b/docs/topics/i18n/translation.txt index 48a6d6277e5e..39f6e5ff49cb 100644 --- a/docs/topics/i18n/translation.txt +++ b/docs/topics/i18n/translation.txt @@ -37,6 +37,13 @@ from your :setting:`TEMPLATE_CONTEXT_PROCESSORS` setting. controls if Django should implement format localization. See :doc:`/topics/i18n/formatting` for more details. +.. note:: + + Make sure you've activated translation for your project (the fastest way is + to check if :setting:`MIDDLEWARE_CLASSES` includes + :mod:`django.middleware.locale.LocaleMiddleware`). If you haven't yet, + see :ref:`how-django-discovers-language-preference`. + Internationalization: in Python code ==================================== From 03e79c3386ba495438f05b59f270c8d14055c231 Mon Sep 17 00:00:00 2001 From: Tim Saylor Date: Wed, 13 Jun 2012 13:42:18 -0500 Subject: [PATCH 079/367] [1.4.X] Fixed a documentation typo on the widget page. Backport of f8ef93a657 from master --- docs/ref/forms/widgets.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index 88d0d706cdcd..fb7657349aa9 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -75,7 +75,7 @@ changing :attr:`ChoiceField.choices` will update :attr:`Select.choices`. For example:: >>> from django import forms - >>> CHOICES = (('1', 'First',), ('2', 'Second',))) + >>> CHOICES = (('1', 'First',), ('2', 'Second',)) >>> choice_field = forms.ChoiceField(widget=forms.RadioSelect, choices=CHOICES) >>> choice_field.choices [('1', 'First'), ('2', 'Second')] From 232a30804446739602d883e87c3b809c4f99d282 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 19 Aug 2012 18:46:46 -0400 Subject: [PATCH 080/367] [1.4.X] Fixed #17180 - Emphasized the need to load the i18n template tag in each template that uses translations. Thanks stefan.freyr for the suggestion and buddylindsey for the draft patch. Backport of 514a0013cd from master --- docs/topics/i18n/translation.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/topics/i18n/translation.txt b/docs/topics/i18n/translation.txt index 39f6e5ff49cb..fbed27d1819e 100644 --- a/docs/topics/i18n/translation.txt +++ b/docs/topics/i18n/translation.txt @@ -463,6 +463,9 @@ Internationalization: in template code Translations in :doc:`Django templates ` uses two template tags and a slightly different syntax than in Python code. To give your template access to these tags, put ``{% load i18n %}`` toward the top of your template. +As with all template tags, this tag needs to be loaded in all templates which +use translations, even those templates that extend from other templates which +have already loaded the ``i18n`` tag. .. templatetag:: trans From eaa6e4e2d164d71b14b8504ef66928e9e3e18d7d Mon Sep 17 00:00:00 2001 From: Jeremy Cowgar Date: Thu, 17 May 2012 18:53:57 -0400 Subject: [PATCH 081/367] [1.4.X] Added load i18n code to the base wizard form template documentation as it uses the trans tag. Backport of c23d306df8 from master --- docs/ref/contrib/formtools/form-wizard.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ref/contrib/formtools/form-wizard.txt b/docs/ref/contrib/formtools/form-wizard.txt index a3d9673db9af..7aafbe89f352 100644 --- a/docs/ref/contrib/formtools/form-wizard.txt +++ b/docs/ref/contrib/formtools/form-wizard.txt @@ -186,6 +186,7 @@ Here's a full example template: .. code-block:: html+django {% extends "base.html" %} + {% load i18n %} {% block head %} {{ wizard.form.media }} From b05d2f51b806da184fb8c56619f4d275a3294bb5 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 19 Aug 2012 20:13:09 -0400 Subject: [PATCH 082/367] [1.4.X] Fixed typo in form wizard docs. Backport of 3631db88cb from master --- docs/ref/contrib/formtools/form-wizard.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/contrib/formtools/form-wizard.txt b/docs/ref/contrib/formtools/form-wizard.txt index 7aafbe89f352..6f62bb0f95b1 100644 --- a/docs/ref/contrib/formtools/form-wizard.txt +++ b/docs/ref/contrib/formtools/form-wizard.txt @@ -528,7 +528,7 @@ We define our wizard in a ``views.py``:: We need to add the ``ContactWizard`` to our ``urls.py`` file:: - from django.conf.urls import pattern + from django.conf.urls import patterns from myapp.forms import ContactForm1, ContactForm2 from myapp.views import ContactWizard, show_message_form_condition From 42aee6ffe5f16852347e0cf069447950e9d2ef85 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 21 Aug 2012 17:32:53 -0400 Subject: [PATCH 083/367] [1.4.x] Fixed #14885 - Clarified that ModelForm cleaning may not fully complete if the form is invalid. Thanks Ben Sturmfels for the patch. Backport of 3fd89d99036696ba08dd2dd7e20a5b375f85d23b from master. --- docs/topics/forms/modelforms.txt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index f49c9ba6df29..a29da7074244 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -200,10 +200,13 @@ The ``is_valid()`` method and ``errors`` ---------------------------------------- The first time you call ``is_valid()`` or access the ``errors`` attribute of a -``ModelForm`` triggers form validation as well as :ref:`model validation -`. This has the side-effect of cleaning the model you pass -to the ``ModelForm`` constructor. For instance, calling ``is_valid()`` on your -form will convert any date fields on your model to actual date objects. +``ModelForm`` triggers :ref:`form validation ` as +well as :ref:`model validation `. This has the side-effect +of cleaning the model you pass to the ``ModelForm`` constructor. For instance, +calling ``is_valid()`` on your form will convert any date fields on your model +to actual date objects. If form validation fails, only some of the updates +may be applied. For this reason, you'll probably want to avoid reusing the +model instance. The ``save()`` method From 27c2ccc1ea0d08752a480aae30d48be6126d21ff Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 21 Aug 2012 16:29:16 -0400 Subject: [PATCH 084/367] [1.4.x] Fixed #18637 - Updated some documentation for aspects of models that are ModelForm specific, not admin specific. Thanks Ben Sturmfels for the patch. Backport of 13d47c3f338e1e9a5da943b97b5334c0523d2e2c from master. --- docs/ref/models/fields.txt | 88 +++++++++++++++++++------------------- docs/topics/db/models.txt | 28 ++++++------ 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 6590a413d7d6..aadfe25ae084 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -71,8 +71,8 @@ If ``True``, the field is allowed to be blank. Default is ``False``. Note that this is different than :attr:`~Field.null`. :attr:`~Field.null` is purely database-related, whereas :attr:`~Field.blank` is validation-related. If -a field has ``blank=True``, validation on Django's admin site will allow entry -of an empty value. If a field has ``blank=False``, the field will be required. +a field has ``blank=True``, form validation will allow entry of an empty value. +If a field has ``blank=False``, the field will be required. .. _field-choices: @@ -82,12 +82,11 @@ of an empty value. If a field has ``blank=False``, the field will be required. .. attribute:: Field.choices An iterable (e.g., a list or tuple) of 2-tuples to use as choices for this -field. +field. If this is given, the default form widget will be a select box with +these choices instead of the standard text field. -If this is given, Django's admin will use a select box instead of the standard -text field and will limit choices to the choices given. - -A choices list looks like this:: +The first element in each tuple is the actual value to be stored, and the +second element is the human-readable name. For example:: YEAR_IN_SCHOOL_CHOICES = ( ('FR', 'Freshman'), @@ -203,8 +202,8 @@ callable it will be called every time a new object is created. .. attribute:: Field.editable -If ``False``, the field will not be editable in the admin or via forms -automatically generated from the model class. Default is ``True``. +If ``False``, the field will not be displayed in the admin or any other +:class:`~django.forms.ModelForm`. Default is ``True``. ``error_messages`` ------------------ @@ -226,11 +225,11 @@ the `Field types`_ section below. .. attribute:: Field.help_text -Extra "help" text to be displayed under the field on the object's admin form. -It's useful for documentation even if your object doesn't have an admin form. +Extra "help" text to be displayed with the form widget. It's useful for +documentation even if your field isn't used on a form. -Note that this value is *not* HTML-escaped when it's displayed in the admin -interface. This lets you include HTML in :attr:`~Field.help_text` if you so +Note that this value is *not* HTML-escaped in automatically-generated +forms. This lets you include HTML in :attr:`~Field.help_text` if you so desire. For example:: help_text="Please use the following format: YYYY-MM-DD." @@ -261,7 +260,7 @@ Only one primary key is allowed on an object. If ``True``, this field must be unique throughout the table. -This is enforced at the database level and at the Django admin-form level. If +This is enforced at the database level and by model validation. If you try to save a model with a duplicate value in a :attr:`~Field.unique` field, a :exc:`django.db.IntegrityError` will be raised by the model's :meth:`~django.db.models.Model.save` method. @@ -281,7 +280,7 @@ For example, if you have a field ``title`` that has ``unique_for_date="pub_date"``, then Django wouldn't allow the entry of two records with the same ``title`` and ``pub_date``. -This is enforced at the Django admin-form level but not at the database level. +This is enforced by model validation but not at the database level. ``unique_for_month`` -------------------- @@ -343,7 +342,7 @@ otherwise. See :ref:`automatic-primary-key-fields`. A 64 bit integer, much like an :class:`IntegerField` except that it is guaranteed to fit numbers from -9223372036854775808 to 9223372036854775807. The -admin represents this as an ```` (a single-line input). +default form widget for this field is a :class:`~django.forms.TextInput`. ``BooleanField`` @@ -353,7 +352,8 @@ admin represents this as an ```` (a single-line input). A true/false field. -The admin represents this as a checkbox. +The default form widget for this field is a +:class:`~django.forms.CheckboxInput`. If you need to accept :attr:`~Field.null` values then use :class:`NullBooleanField` instead. @@ -372,7 +372,7 @@ A string field, for small- to large-sized strings. For large amounts of text, use :class:`~django.db.models.TextField`. -The admin represents this as an ```` (a single-line input). +The default form widget for this field is a :class:`~django.forms.TextInput`. :class:`CharField` has one extra required argument: @@ -425,9 +425,10 @@ optional arguments: for creation of timestamps. Note that the current date is *always* used; it's not just a default value that you can override. -The admin represents this as an ```` with a JavaScript -calendar, and a shortcut for "Today". Includes an additional ``invalid_date`` -error message key. +The default form widget for this field is a +:class:`~django.forms.TextInput`. The admin adds a JavaScript calendar, +and a shortcut for "Today". Includes an additional ``invalid_date`` error +message key. .. note:: As currently implemented, setting ``auto_now`` or ``auto_now_add`` to @@ -442,8 +443,9 @@ error message key. A date and time, represented in Python by a ``datetime.datetime`` instance. Takes the same extra arguments as :class:`DateField`. -The admin represents this as two ```` fields, with -JavaScript shortcuts. +The default form widget for this field is a single +:class:`~django.forms.TextInput`. The admin uses two separate +:class:`~django.forms.TextInput` widgets with JavaScript shortcuts. ``DecimalField`` ---------------- @@ -472,7 +474,7 @@ decimal places:: models.DecimalField(..., max_digits=19, decimal_places=10) -The admin represents this as an ```` (a single-line input). +The default form widget for this field is a :class:`~django.forms.TextInput`. .. note:: @@ -541,8 +543,8 @@ Also has one optional argument: Optional. A storage object, which handles the storage and retrieval of your files. See :doc:`/topics/files` for details on how to provide this object. -The admin represents this field as an ```` (a file-upload -widget). +The default form widget for this field is a +:class:`~django.forms.widgets.FileInput`. Using a :class:`FileField` or an :class:`ImageField` (see below) in a model takes a few steps: @@ -710,7 +712,7 @@ can change the maximum length using the :attr:`~CharField.max_length` argument. A floating-point number represented in Python by a ``float`` instance. -The admin represents this as an ```` (a single-line input). +The default form widget for this field is a :class:`~django.forms.TextInput`. .. _floatfield_vs_decimalfield: @@ -761,16 +763,16 @@ length using the :attr:`~CharField.max_length` argument. .. class:: IntegerField([**options]) -An integer. The admin represents this as an ```` (a -single-line input). +An integer. The default form widget for this field is a +:class:`~django.forms.TextInput`. ``IPAddressField`` ------------------ .. class:: IPAddressField([**options]) -An IP address, in string format (e.g. "192.0.2.30"). The admin represents this -as an ```` (a single-line input). +An IP address, in string format (e.g. "192.0.2.30"). The default form widget +for this field is a :class:`~django.forms.TextInput`. ``GenericIPAddressField`` ------------------------- @@ -780,8 +782,8 @@ as an ```` (a single-line input). .. versionadded:: 1.4 An IPv4 or IPv6 address, in string format (e.g. ``192.0.2.30`` or -``2a02:42fe::4``). The admin represents this as an ```` -(a single-line input). +``2a02:42fe::4``). The default form widget for this field is a +:class:`~django.forms.TextInput`. The IPv6 address normalization follows :rfc:`4291#section-2.2` section 2.2, including using the IPv4 format suggested in paragraph 3 of that section, like @@ -808,8 +810,8 @@ are converted to lowercase. .. class:: NullBooleanField([**options]) Like a :class:`BooleanField`, but allows ``NULL`` as one of the options. Use -this instead of a :class:`BooleanField` with ``null=True``. The admin represents -this as a ```` +.. _selector-widgets: + +Selector and checkbox widgets +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ``CheckboxInput`` ~~~~~~~~~~~~~~~~~ @@ -435,6 +510,50 @@ commonly used groups of widgets: ... +.. _file-upload-widgets: + +File upload widgets +^^^^^^^^^^^^^^^^^^^ + +``FileInput`` +~~~~~~~~~~~~~ + +.. class:: FileInput + + File upload input: ```` + +``ClearableFileInput`` +~~~~~~~~~~~~~~~~~~~~~~ + +.. class:: ClearableFileInput + + .. versionadded:: 1.3 + + File upload input: ````, with an additional checkbox + input to clear the field's value, if the field is not required and has + initial data. + +.. _composite-widgets: + +Composite widgets +^^^^^^^^^^^^^^^^^ + +``MultipleHiddenInput`` +~~~~~~~~~~~~~~~~~~~~~~~ + +.. class:: MultipleHiddenInput + + Multiple ```` widgets. + + A widget that handles multiple hidden widgets for fields that have a list + of values. + + .. attribute:: MultipleHiddenInput.choices + + This attribute is optional when the field does not have a + :attr:`~Field.choices` attribute. If it does, it will override anything + you set here when the attribute is updated on the :class:`Field`. + ``MultiWidget`` ~~~~~~~~~~~~~~~ diff --git a/docs/topics/forms/media.txt b/docs/topics/forms/media.txt index 0eb3e91b3adf..36783f1082a5 100644 --- a/docs/topics/forms/media.txt +++ b/docs/topics/forms/media.txt @@ -38,6 +38,8 @@ in a form suitable for easy inclusion on your Web page. whichever toolkit suits your requirements. Django is able to integrate with any JavaScript toolkit. +.. _media-as-a-static-definition: + Media as a static definition ---------------------------- @@ -78,10 +80,8 @@ A dictionary describing the CSS files required for various forms of output media. The values in the dictionary should be a tuple/list of file names. See -`the section on media paths`_ for details of how to specify paths to media -files. - -.. _the section on media paths: `Paths in media definitions`_ +:ref:`the section on media paths ` for details of how to +specify paths to media files. The keys in the dictionary are the output media types. These are the same types accepted by CSS files in media declarations: 'all', 'aural', 'braille', @@ -117,8 +117,8 @@ If this last CSS definition were to be rendered, it would become the following H ``js`` ~~~~~~ -A tuple describing the required JavaScript files. See -`the section on media paths`_ for details of how to specify paths to media +A tuple describing the required JavaScript files. See :ref:`the section on +media paths ` for details of how to specify paths to media files. ``extend`` @@ -164,10 +164,10 @@ declaration to the media declaration:: If you require even more control over media inheritance, define your media -using a `dynamic property`_. Dynamic properties give you complete control over -which media files are inherited, and which are not. +using a :ref:`dynamic property `. Dynamic properties give +you complete control over which media files are inherited, and which are not. -.. _dynamic property: `Media as a dynamic property`_ +.. _dynamic-property: Media as a dynamic property --------------------------- @@ -198,9 +198,9 @@ Paths in media definitions .. versionchanged:: 1.3 Paths used to specify media can be either relative or absolute. If a path -starts with '/', 'http://' or 'https://', it will be interpreted as an absolute -path, and left as-is. All other paths will be prepended with the value of -the appropriate prefix. +starts with ``/``, ``http://`` or ``https://``, it will be interpreted as an +absolute path, and left as-is. All other paths will be prepended with the value +of the appropriate prefix. As part of the introduction of the :doc:`staticfiles app ` two new settings were added From 81c77d24eff8d97752681f413451a170861e865d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 15 Sep 2012 07:16:07 -0400 Subject: [PATCH 107/367] [1.4.X] Added an example of using a form wizard with different templates; thanks Lorin Hochstein for the patch. Backport of 553583958d from master --- docs/ref/contrib/formtools/form-wizard.txt | 62 +++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/ref/contrib/formtools/form-wizard.txt b/docs/ref/contrib/formtools/form-wizard.txt index 6f62bb0f95b1..5794393ba44e 100644 --- a/docs/ref/contrib/formtools/form-wizard.txt +++ b/docs/ref/contrib/formtools/form-wizard.txt @@ -155,7 +155,8 @@ or the :meth:`~django.views.generic.base.TemplateResponseMixin.get_template_names()` method, which are documented in the :class:`~django.views.generic.base.TemplateResponseMixin` documentation. The -latter one allows you to use a different template for each form. +latter one allows you to use a different template for each form (:ref:`see the +example below `). This template expects a ``wizard`` object that has various items attached to it: @@ -238,6 +239,65 @@ wizard's :meth:`as_view` method takes a list of your (r'^contact/$', ContactWizard.as_view([ContactForm1, ContactForm2])), ) +.. _wizard-template-for-each-form: + +Using a different template for each form +---------------------------------------- + +As mentioned above, you may specify a different template for each form. +Consider an example using a form wizard to implement a multi-step checkout +process for an online store. In the first step, the user specifies a billing +and shipping address. In the second step, the user chooses payment type. If +they chose to pay by credit card, they will enter credit card information in +the next step. In the final step, they will confirm the purchase. + +Here's what the view code might look like:: + + from django.http import HttpResponseRedirect + from django.contrib.formtools.wizard.views import SessionWizardView + + FORMS = [("address", myapp.forms.AddressForm), + ("paytype", myapp.forms.PaymentChoiceForm), + ("cc", myapp.forms.CreditCardForm), + ("confirmation", myapp.forms.OrderForm)] + + TEMPLATES = {"address": "checkout/billingaddress.html", + "paytype": "checkout/paymentmethod.html", + "cc": "checkout/creditcard.html", + "confirmation": "checkout/confirmation.html"} + + def pay_by_credit_card(wizard): + """Return true if user opts to pay by credit card""" + # Get cleaned data from payment step + cleaned_data = wizard.get_cleaned_data_for_step('paytype') or {'method': 'none'} + # Return true if the user selected credit card + return cleaned_data['method'] == 'cc' + + + class OrderWizard(SessionWizardView): + def get_template_names(self): + return [TEMPLATES[self.steps.current]] + + def done(self, form_list, **kwargs): + do_something_with_the_form_data(form_list) + return HttpResponseRedirect('/page-to-redirect-to-when-done/') + ... + +The ``urls.py`` file would contain something like:: + + urlpatterns = patterns('', + (r'^checkout/$', OrderWizard.as_view(FORMS, condition_dict={'cc': pay_by_credit_card})), + ) + +Note that the ``OrderWizard`` object is initialized with a list of pairs. +The first element in the pair is a string that corresponds to the name of the +step and the second is the form class. + +In this example, the +:meth:`~django.views.generic.base.TemplateResponseMixin.get_template_names()` +method returns a list containing a single template, which is selected based on +the name of the current step. + .. _wizardview-advanced-methods: Advanced ``WizardView`` methods From 18d88a169fecf7efa2fba2ba2867e7a37084c7fc Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 15 Sep 2012 07:37:33 -0400 Subject: [PATCH 108/367] [1.4.x] Fixed #16929 - Documented how to extend UserAdmin with UserProfile fields; thanks charettes for the draft example. Backport of 22242c510f84c53803afe2907649c892cb1b3d9a from master. --- docs/topics/auth.txt | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/topics/auth.txt b/docs/topics/auth.txt index 35777d075ee5..efc6e7841309 100644 --- a/docs/topics/auth.txt +++ b/docs/topics/auth.txt @@ -664,6 +664,36 @@ the handler, if ``created`` is ``True``, create the associated user profile:: .. seealso:: :doc:`/topics/signals` for more information on Django's signal dispatcher. +Adding UserProfile fields to the admin +-------------------------------------- + +To add the UserProfile fields to the user page in the admin, define an +:class:`~django.contrib.admin.InlineModelAdmin` (for this example, we'll use a +:class:`~django.contrib.admin.StackedInline`) in your app's ``admin.py`` and +add it to a ``UserAdmin`` class which is registered with the +:class:`~django.contrib.auth.models.User` class:: + + from django.contrib import admin + from django.contrib.auth.admin import UserAdmin + from django.contrib.auth.models import User + + from my_user_profile_app.models import UserProfile + + # Define an inline admin descriptor for UserProfile model + # which acts a bit like a singleton + class UserProfileInline(admin.StackedInline): + model = UserProfile + can_delete = False + verbose_name_plural = 'profile' + + # Define a new User admin + class UserAdmin(UserAdmin): + inlines = (UserProfileInline, ) + + # Re-register UserAdmin + admin.site.unregister(User) + admin.site.register(User, UserAdmin) + Authentication in Web requests ============================== From 421ce44e8bb7386d1f5826d0473a3e24f6075d35 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 15 Sep 2012 08:15:54 -0400 Subject: [PATCH 109/367] [1.4.x] Fixed #18131 - Documented ContentTypeManager.get_for_id; thanks sir_sigurd for the report. Backport of 93e6733e4cbbdad34f1f0f59303ae01f577e4e58 from master. --- docs/ref/contrib/contenttypes.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ref/contrib/contenttypes.txt b/docs/ref/contrib/contenttypes.txt index ecfcff0c0f33..ae364a53be98 100644 --- a/docs/ref/contrib/contenttypes.txt +++ b/docs/ref/contrib/contenttypes.txt @@ -187,6 +187,14 @@ The ``ContentTypeManager`` probably won't ever need to call this method yourself; Django will call it automatically when it's needed. + .. method:: get_for_id(id) + + Lookup a :class:`~django.contrib.contenttypes.models.ContentType` by ID. + Since this method uses the same shared cache as + :meth:`~django.contrib.contenttypes.models.ContentTypeManager.get_for_model`, + it's preferred to use this method over the usual + ``ContentType.objects.get(pk=id)`` + .. method:: get_for_model(model) Takes either a model class or an instance of a model, and returns the From 336dfc3413b22bc5a5008bba7ff383886da96d60 Mon Sep 17 00:00:00 2001 From: Julien Phalip Date: Sat, 15 Sep 2012 16:33:56 -0700 Subject: [PATCH 110/367] [1.4.X] Fixed #18530 -- Fixed a small regression in the admin filters where wrongly formatted dates passed as url parameters caused an unhandled ValidationError. Thanks to david for the report. --- django/contrib/admin/filters.py | 11 +++++++---- tests/regressiontests/admin_views/admin.py | 2 +- tests/regressiontests/admin_views/models.py | 1 + tests/regressiontests/admin_views/tests.py | 4 ++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/django/contrib/admin/filters.py b/django/contrib/admin/filters.py index 76b8d30c0d38..549c15934b8d 100644 --- a/django/contrib/admin/filters.py +++ b/django/contrib/admin/filters.py @@ -8,13 +8,13 @@ import datetime from django.db import models -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ from django.utils import timezone - from django.contrib.admin.util import (get_model_from_relation, reverse_field_path, get_limit_choices_to_from_path, prepare_lookup_value) +from django.contrib.admin.options import IncorrectLookupParameters class ListFilter(object): title = None # Human-readable title to appear in the right sidebar. @@ -129,7 +129,10 @@ def has_output(self): return True def queryset(self, request, queryset): - return queryset.filter(**self.used_parameters) + try: + return queryset.filter(**self.used_parameters) + except ValidationError as e: + raise IncorrectLookupParameters(e) @classmethod def register(cls, test, list_filter_class, take_priority=False): @@ -302,7 +305,7 @@ def __init__(self, field, request, params, model, model_admin, field_path): else: # field is a models.DateField today = now.date() tomorrow = today + datetime.timedelta(days=1) - + self.lookup_kwarg_since = '%s__gte' % field_path self.lookup_kwarg_until = '%s__lt' % field_path self.links = ( diff --git a/tests/regressiontests/admin_views/admin.py b/tests/regressiontests/admin_views/admin.py index d9607496c3b2..3369c557b7d5 100644 --- a/tests/regressiontests/admin_views/admin.py +++ b/tests/regressiontests/admin_views/admin.py @@ -127,7 +127,7 @@ def changelist_view(self, request): class ThingAdmin(admin.ModelAdmin): - list_filter = ('color__warm', 'color__value') + list_filter = ('color__warm', 'color__value', 'pub_date',) class InquisitionAdmin(admin.ModelAdmin): diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py index 17533f9f800d..e158e07603ee 100644 --- a/tests/regressiontests/admin_views/models.py +++ b/tests/regressiontests/admin_views/models.py @@ -113,6 +113,7 @@ class Meta: class Thing(models.Model): title = models.CharField(max_length=20) color = models.ForeignKey(Color, limit_choices_to={'warm': True}) + pub_date = models.DateField(blank=True, null=True) def __unicode__(self): return self.title diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index bc66c9bff603..8835e816dc3b 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -456,6 +456,10 @@ def testIncorrectLookupParameters(self): response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit, {'color__id__exact': 'StringNotInteger!'}) self.assertRedirects(response, '/test_admin/%s/admin_views/thing/?e=1' % self.urlbit) + # Regression test for #18530 + response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit, {'pub_date__gte': 'foo'}) + self.assertRedirects(response, '/test_admin/%s/admin_views/thing/?e=1' % self.urlbit) + def testIsNullLookups(self): """Ensure is_null is handled correctly.""" Article.objects.create(title="I Could Go Anywhere", content="Versatile", date=datetime.datetime.now()) From 3a64adef611ba152eb96d77645480e1953825803 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 19 Sep 2012 07:13:10 -0400 Subject: [PATCH 111/367] [1.4.X] Fixed #13586 - Added an example of how to connect a m2m_changed signal handler. Backport of 1360bd4186 from master --- docs/ref/signals.txt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/ref/signals.txt b/docs/ref/signals.txt index 3917b52b9680..486e53b679c9 100644 --- a/docs/ref/signals.txt +++ b/docs/ref/signals.txt @@ -277,13 +277,22 @@ like this:: # ... toppings = models.ManyToManyField(Topping) -If we would do something like this: +If we connected a handler like this:: + + def toppings_changed(sender, **kwargs): + # Do something + pass + + m2m_changed.connect(toppings_changed, sender=Pizza.toppings.through) + +and then did something like this:: >>> p = Pizza.object.create(...) >>> t = Topping.objects.create(...) >>> p.toppings.add(t) -the arguments sent to a :data:`m2m_changed` handler would be: +the arguments sent to a :data:`m2m_changed` handler (``topppings_changed`` in +the example above) would be: ============== ============================================================ Argument Value From 57cdbf3bf8e80eb5c265852bfa5eb4b3f514809e Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 19 Sep 2012 16:36:34 -0400 Subject: [PATCH 112/367] [1.4.X] Fixed #14829 - Added references to CBVs in the URLConf docs; thanks Andrew Willey for the suggestion. Backport of acd74ffa35 from master --- docs/topics/http/urls.txt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/topics/http/urls.txt b/docs/topics/http/urls.txt index 1c2849d47711..30ae2083133f 100644 --- a/docs/topics/http/urls.txt +++ b/docs/topics/http/urls.txt @@ -55,7 +55,8 @@ algorithm the system follows to determine which Python code to execute: one that matches the requested URL. 4. Once one of the regexes matches, Django imports and calls the given - view, which is a simple Python function. The view gets passed an + view, which is a simple Python function (or a :doc:`class based view + `). The view gets passed an :class:`~django.http.HttpRequest` as its first argument and any values captured in the regex as remaining arguments. @@ -673,6 +674,15 @@ The style you use is up to you. Note that if you use this technique -- passing objects rather than strings -- the view prefix (as explained in "The view prefix" above) will have no effect. +Note that :doc:`class based views` must be +imported:: + + from mysite.views import ClassBasedView + + urlpatterns = patterns('', + (r'^myview/$', ClassBasedView.as_view()), + ) + .. _naming-url-patterns: Naming URL patterns From 1189dca471b9d2c01a570526fbbcddf659dfff43 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 19 Sep 2012 16:09:46 -0400 Subject: [PATCH 113/367] [1.4.X] Fixed #15325 - Added a link to RelatedManager in the ManytoManyField docs; thanks jammon for the suggestion. Backport of 0fdfcee257 from master --- docs/ref/models/fields.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index a38fc96fb8a0..cca6ec51dc46 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1082,6 +1082,9 @@ the model is related. This works exactly the same as it does for :class:`ForeignKey`, including all the options regarding :ref:`recursive ` and :ref:`lazy ` relationships. +Related objects can be added, removed, or created with the field's +:class:`~django.db.models.fields.related.RelatedManager`. + Database Representation ~~~~~~~~~~~~~~~~~~~~~~~ From bd514f28e49887e8555c289546286dc27a17ddcc Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 22 Sep 2012 07:08:40 -0400 Subject: [PATCH 114/367] [1.4.X] Fixed #18057 - Documented that caches are not cleared after each test; thanks guettli for the suggestion. Backport of 2aaa467a2a from master --- docs/topics/testing.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt index cd7ef9361f8f..ad4ef50550af 100644 --- a/docs/topics/testing.txt +++ b/docs/topics/testing.txt @@ -494,6 +494,13 @@ file, all Django tests run with :setting:`DEBUG`\=False. This is to ensure that the observed output of your code matches what will be seen in a production setting. +Caches are not cleared after each test, and running "manage.py test fooapp" can +insert data from the tests into the cache of a live system if you run your +tests in production because, unlike databases, a separate "test cache" is not +used. This behavior `may change`_ in the future. + +.. _may change: https://code.djangoproject.com/ticket/11505 + Understanding the test output ----------------------------- From 1f537335d9ff659cb0996d6523ad8ab7b3c49f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Thu, 27 Sep 2012 15:36:30 +0300 Subject: [PATCH 115/367] [1.4.x] Fixed #18979 -- Avoid endless loop caused by "val in PermLookupDict" Fixed by defining __iter__ which raises TypeError. This was done to PermWrapper earlier. Backport of 50d573d2c0b3e17cbf1aa240b03b52e4ad0c32cd --- django/contrib/auth/context_processors.py | 5 +++ .../contrib/auth/tests/context_processors.py | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/django/contrib/auth/context_processors.py b/django/contrib/auth/context_processors.py index 3ffab01e94bb..ea9f95c4f0fa 100644 --- a/django/contrib/auth/context_processors.py +++ b/django/contrib/auth/context_processors.py @@ -11,6 +11,11 @@ def __repr__(self): def __getitem__(self, perm_name): return self.user.has_perm("%s.%s" % (self.module_name, perm_name)) + def __iter__(self): + # To fix 'item in perms.someapp' and __getitem__ iteraction we need to + # define __iter__. See #18979 for details. + raise TypeError("PermLookupDict is not iterable.") + def __nonzero__(self): return self.user.has_module_perms(self.module_name) diff --git a/django/contrib/auth/tests/context_processors.py b/django/contrib/auth/tests/context_processors.py index dcd50fc6cca9..55cbdd413e5f 100644 --- a/django/contrib/auth/tests/context_processors.py +++ b/django/contrib/auth/tests/context_processors.py @@ -2,12 +2,56 @@ from django.conf import global_settings from django.contrib.auth import authenticate +from django.contrib.auth.context_processors import PermWrapper, PermLookupDict from django.db.models import Q from django.template import context from django.test import TestCase from django.test.utils import override_settings +class MockUser(object): + def has_module_perm(self, perm): + if perm == 'mockapp.someapp': + return True + return False + + def has_perm(self, perm): + if perm == 'someperm': + return True + return False + + +class PermWrapperTests(TestCase): + """ + Test some details of the PermWrapper implementation. + """ + class EQLimiterObject(object): + """ + This object makes sure __eq__ will not be called endlessly. + """ + def __init__(self): + self.eq_calls = 0 + + def __eq__(self, other): + if self.eq_calls > 0: + return True + self.eq_calls += 1 + return False + + def test_permwrapper_in(self): + """ + Test that 'something' in PermWrapper doesn't end up in endless loop. + """ + perms = PermWrapper(MockUser()) + with self.assertRaises(TypeError): + self.EQLimiterObject() in perms + + def test_permlookupdict_in(self): + pldict = PermLookupDict(MockUser(), 'mockapp') + with self.assertRaises(TypeError): + self.EQLimiterObject() in pldict + + class AuthContextProcessorTests(TestCase): """ Tests for the ``django.contrib.auth.context_processors.auth`` processor From 4dba4ed548c0abdf4cdc0f3781d1c4d89a567d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Sun, 30 Sep 2012 16:36:01 +0300 Subject: [PATCH 116/367] [1.4.x] -- Fixed Python 2.5 compatibility issues --- django/contrib/admin/filters.py | 2 +- django/contrib/auth/tests/context_processors.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/django/contrib/admin/filters.py b/django/contrib/admin/filters.py index 549c15934b8d..6a97fb5c0b72 100644 --- a/django/contrib/admin/filters.py +++ b/django/contrib/admin/filters.py @@ -131,7 +131,7 @@ def has_output(self): def queryset(self, request, queryset): try: return queryset.filter(**self.used_parameters) - except ValidationError as e: + except ValidationError, e: raise IncorrectLookupParameters(e) @classmethod diff --git a/django/contrib/auth/tests/context_processors.py b/django/contrib/auth/tests/context_processors.py index 55cbdd413e5f..d387ffc8b2cd 100644 --- a/django/contrib/auth/tests/context_processors.py +++ b/django/contrib/auth/tests/context_processors.py @@ -43,13 +43,15 @@ def test_permwrapper_in(self): Test that 'something' in PermWrapper doesn't end up in endless loop. """ perms = PermWrapper(MockUser()) - with self.assertRaises(TypeError): + def raises(): self.EQLimiterObject() in perms + self.assertRaises(raises, TypeError) def test_permlookupdict_in(self): pldict = PermLookupDict(MockUser(), 'mockapp') - with self.assertRaises(TypeError): + def raises(): self.EQLimiterObject() in pldict + self.assertRaises(raises, TypeError) class AuthContextProcessorTests(TestCase): From cf482d6e2a322a8c84570ae282b774fa09491c98 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 30 Sep 2012 13:37:25 -0400 Subject: [PATCH 117/367] [1.4.X] Fixed #15338 - Documented django.utils.decorators Backport of d0345b7114 from master --- docs/ref/utils.txt | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index cd799762650e..dd86b3a7b171 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -166,6 +166,37 @@ The functions defined in this module share the following properties: ``tzinfo`` attribute is a :class:`~django.utils.tzinfo.FixedOffset` instance. +``django.utils.decorators`` +=========================== + +.. module:: django.utils.decorators + :synopsis: Functions that help with creating decorators for views. + +.. function:: method_decorator(decorator) + + Converts a function decorator into a method decorator. See :ref:`decorating + class based views` for example usage. + +.. function:: decorator_from_middleware(middleware_class) + + Given a middleware class, returns a view decorator. This lets you use + middleware functionality on a per-view basis. The middleware is created + with no params passed. + +.. function:: decorator_from_middleware_with_args(middleware_class) + + Like ``decorator_from_middleware``, but returns a function + that accepts the arguments to be passed to the middleware_class. + For example, the :func:`~django.views.decorators.cache.cache_page` + decorator is created from the + :class:`~django.middleware.cache.CacheMiddleware` like this:: + + cache_page = decorator_from_middleware_with_args(CacheMiddleware) + + @cache_page(3600) + def my_view(request): + pass + ``django.utils.encoding`` ========================= From b1462e0a36bc6bb2c221bda107da74a6381b9675 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 3 Oct 2012 06:58:16 -0400 Subject: [PATCH 118/367] [1.4.X] Fixed #18413 - Noted that a model's files are not deleted when the model is deleted. Thanks lawgon for the report. Backport of 1c03b23567 from master --- docs/ref/models/fields.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index cca6ec51dc46..c8b9db33f695 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -672,6 +672,11 @@ the field. Note: This method will close the file if it happens to be open when The optional ``save`` argument controls whether or not the instance is saved after the file has been deleted. Defaults to ``True``. +Note that when a model is deleted, related files are not deleted. If you need +to cleanup orphaned files, you'll need to handle it yourself (for instance, +with a custom management command that can be run manually or scheduled to run +periodically via e.g. cron). + ``FilePathField`` ----------------- From 8868a067e02b4fe9f1c669f06e90fc28171b9758 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 3 Oct 2012 14:43:36 -0400 Subject: [PATCH 119/367] [1.4.X] Fixed #19006 - Quoted filenames in Content-Disposition header. Backport of 234ca6c61d from master --- docs/howto/outputting-csv.txt | 4 ++-- docs/howto/outputting-pdf.txt | 6 +++--- docs/ref/request-response.txt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/howto/outputting-csv.txt b/docs/howto/outputting-csv.txt index 1a606069b85f..bcc6f3827bd7 100644 --- a/docs/howto/outputting-csv.txt +++ b/docs/howto/outputting-csv.txt @@ -21,7 +21,7 @@ Here's an example:: def some_view(request): # Create the HttpResponse object with the appropriate CSV header. response = HttpResponse(mimetype='text/csv') - response['Content-Disposition'] = 'attachment; filename=somefilename.csv' + response['Content-Disposition'] = 'attachment; filename="somefilename.csv"' writer = csv.writer(response) writer.writerow(['First row', 'Foo', 'Bar', 'Baz']) @@ -93,7 +93,7 @@ Here's an example, which generates the same CSV file as above:: def some_view(request): # Create the HttpResponse object with the appropriate CSV header. response = HttpResponse(mimetype='text/csv') - response['Content-Disposition'] = 'attachment; filename=somefilename.csv' + response['Content-Disposition'] = 'attachment; filename="somefilename.csv"' # The data is hard-coded here, but you could load it from a database or # some other source. diff --git a/docs/howto/outputting-pdf.txt b/docs/howto/outputting-pdf.txt index a30b10f7b541..799cd24ae4af 100644 --- a/docs/howto/outputting-pdf.txt +++ b/docs/howto/outputting-pdf.txt @@ -52,7 +52,7 @@ Here's a "Hello World" example:: def some_view(request): # Create the HttpResponse object with the appropriate PDF headers. response = HttpResponse(mimetype='application/pdf') - response['Content-Disposition'] = 'attachment; filename=somefilename.pdf' + response['Content-Disposition'] = 'attachment; filename="somefilename.pdf"' # Create the PDF object, using the response object as its "file." p = canvas.Canvas(response) @@ -87,7 +87,7 @@ mention: the PDF using whatever program/plugin they've been configured to use for PDFs. Here's what that code would look like:: - response['Content-Disposition'] = 'filename=somefilename.pdf' + response['Content-Disposition'] = 'filename="somefilename.pdf"' * Hooking into the ReportLab API is easy: Just pass ``response`` as the first argument to ``canvas.Canvas``. The ``Canvas`` class expects a @@ -119,7 +119,7 @@ Here's the above "Hello World" example rewritten to use :mod:`cStringIO`:: def some_view(request): # Create the HttpResponse object with the appropriate PDF headers. response = HttpResponse(mimetype='application/pdf') - response['Content-Disposition'] = 'attachment; filename=somefilename.pdf' + response['Content-Disposition'] = 'attachment; filename="somefilename.pdf"' buffer = StringIO() diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index f0ee7fff1694..158598173316 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -587,7 +587,7 @@ To tell the browser to treat the response as a file attachment, use the this is how you might return a Microsoft Excel spreadsheet:: >>> response = HttpResponse(my_data, content_type='application/vnd.ms-excel') - >>> response['Content-Disposition'] = 'attachment; filename=foo.xls' + >>> response['Content-Disposition'] = 'attachment; filename="foo.xls"' There's nothing Django-specific about the ``Content-Disposition`` header, but it's easy to forget the syntax, so we've included it here. From a35d7fd1e1c8e2a1e95b6d16949f9fb3977f15cb Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 4 Oct 2012 06:45:22 -0400 Subject: [PATCH 120/367] [1.4.X] Fixed #19051 - Fixed Selenium tearDownClass method; thanks glarrain for the report. Backport of a1a5c0854f from master --- django/contrib/admin/tests.py | 4 ++-- docs/topics/testing.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py index 2491fc65b192..9d94127b3461 100644 --- a/django/contrib/admin/tests.py +++ b/django/contrib/admin/tests.py @@ -25,9 +25,9 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - super(AdminSeleniumWebDriverTestCase, cls).tearDownClass() if hasattr(cls, 'selenium'): cls.selenium.quit() + super(AdminSeleniumWebDriverTestCase, cls).tearDownClass() def wait_until(self, callback, timeout=10): """ @@ -102,4 +102,4 @@ def has_css_class(self, selector, klass): `klass`. """ return (self.selenium.find_element_by_css_selector(selector) - .get_attribute('class').find(klass) != -1) \ No newline at end of file + .get_attribute('class').find(klass) != -1) diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt index ad4ef50550af..53df1f54c5c4 100644 --- a/docs/topics/testing.txt +++ b/docs/topics/testing.txt @@ -1922,8 +1922,8 @@ Then, add a ``LiveServerTestCase``-based test to your app's tests module @classmethod def tearDownClass(cls): - super(MySeleniumTests, cls).tearDownClass() cls.selenium.quit() + super(MySeleniumTests, cls).tearDownClass() def test_login(self): self.selenium.get('%s%s' % (self.live_server_url, '/login/')) From 0636c9583fda5a81a18b47876bcddac62bd2e547 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Fri, 5 Oct 2012 23:14:56 +0200 Subject: [PATCH 121/367] Fixed #19072 -- Corrected an external file path in GeoIP docs Thanks Flavio Curella for the report and the initial patch. --- docs/ref/contrib/gis/geoip.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/ref/contrib/gis/geoip.txt b/docs/ref/contrib/gis/geoip.txt index 62185834ca21..8e8830794b51 100644 --- a/docs/ref/contrib/gis/geoip.txt +++ b/docs/ref/contrib/gis/geoip.txt @@ -23,10 +23,10 @@ to the GPL-licensed `Python GeoIP`__ interface provided by MaxMind. In order to perform IP-based geolocation, the :class:`GeoIP` object requires the GeoIP C libary and either the GeoIP `Country`__ or `City`__ datasets in binary format (the CSV files will not work!). These datasets may be -`downloaded from MaxMind`__. Grab the ``GeoIP.dat.gz`` and ``GeoLiteCity.dat.gz`` -and unzip them in a directory corresponding to what you set -:setting:`GEOIP_PATH` with in your settings. See the example and reference below -for more details. +`downloaded from MaxMind`__. Grab the ``GeoLiteCountry/GeoIP.dat.gz`` and +``GeoLiteCity.dat.gz`` files and unzip them in a directory corresponding to what +you set :setting:`GEOIP_PATH` with in your settings. See the example and +reference below for more details. __ http://www.maxmind.com/app/c __ http://www.maxmind.com/app/python From c06b724a00236b086a5a64510d8bf11978f083f8 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 6 Oct 2012 07:02:11 -0400 Subject: [PATCH 122/367] [1.4.X] Fixed #17435 - Clarified that QuerySet.update returns the number of rows matched Backport of 6d46c740d8 from master --- docs/ref/models/querysets.txt | 3 ++- docs/topics/db/queries.txt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 3db490725efa..da0e3e66cedd 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -1530,7 +1530,8 @@ update .. method:: update(**kwargs) Performs an SQL update query for the specified fields, and returns -the number of rows affected. +the number of rows matched (which may not be equal to the number of rows +updated if some rows already have the new value). For example, to turn comments off for all blog entries published in 2010, you could do this:: diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt index e7b45f04fdfd..2e14abe85038 100644 --- a/docs/topics/db/queries.txt +++ b/docs/topics/db/queries.txt @@ -953,7 +953,8 @@ new value to be the new model instance you want to point to. For example:: >>> Entry.objects.all().update(blog=b) The ``update()`` method is applied instantly and returns the number of rows -affected by the query. The only restriction on the +matched by the query (which may not be equal to the number of rows updated if +some rows already have the new value). The only restriction on the :class:`~django.db.models.query.QuerySet` that is updated is that it can only access one database table, the model's main table. You can filter based on related fields, but you can only update columns in the model's main From 1be0515fe9940a7a727680a775395fe5f0c12b1d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 10 Oct 2012 20:03:27 -0400 Subject: [PATCH 123/367] [1.4.x] Fixed #4501 - Documented how to use coverage.py with Django tests. Thanks krzysiumed for the draft patch. Backport of 7ef2781ca0ce48872e21dce2f322c9e4106d1cfd from master. --- docs/topics/testing.txt | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt index 53df1f54c5c4..3e9c048820a9 100644 --- a/docs/topics/testing.txt +++ b/docs/topics/testing.txt @@ -560,6 +560,49 @@ failed and erroneous tests. If all the tests pass, the return code is 0. This feature is useful if you're using the test-runner script in a shell script and need to test for success or failure at that level. +Speeding up the tests +--------------------- + +In recent versions of Django, the default password hasher is rather slow by +design. If during your tests you are authenticating many users, you may want +to use a custom settings file and set the :setting:`PASSWORD_HASHERS` setting +to a faster hashing algorithm:: + + PASSWORD_HASHERS = ( + 'django.contrib.auth.hashers.MD5PasswordHasher', + ) + +Don't forget to also include in :setting:`PASSWORD_HASHERS` any hashing +algorithm used in fixtures, if any. + +Integration with coverage.py +---------------------------- + +Code coverage describes how much source code has been tested. It shows which +parts of your code are being exercised by tests and which are not. It's an +important part of testing applications, so it's strongly recommended to check +the coverage of your tests. + +Django can be easily integrated with `coverage.py`_, a tool for measuring code +coverage of Python programs. First, `install coverage.py`_. Next, run the +following from your project folder containing ``manage.py``:: + + coverage run --source='.' manage.py test myapp + +This runs your tests and collects coverage data of the executed files in your +project. You can see a report of this data by typing following command:: + + coverage report + +Note that some Django code was executed while running tests, but it is not +listed here because of the ``source`` flag passed to the previous command. + +For more options like annotated HTML listings detailing missed lines, see the +`coverage.py`_ docs. + +.. _coverage.py: http://nedbatchelder.com/code/coverage/ +.. _install coverage.py: http://pypi.python.org/pypi/coverage + Testing tools ============= From 3ac70a5907e054c734ca26e435f1ec1215797b08 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 11 Oct 2012 06:11:52 -0400 Subject: [PATCH 124/367] [1.4.X] Fixed #16817 - Added a guide of code coverage to contributing docs. Thanks Pedro Lima for the draft patch. Backport of 06f5da3d78 from master --- .gitignore | 2 ++ .hgignore | 2 ++ .../contributing/writing-code/unit-tests.txt | 20 +++++++++++++++++++ docs/topics/testing.txt | 2 ++ tests/.coveragerc | 5 +++++ 5 files changed, 31 insertions(+) create mode 100644 tests/.coveragerc diff --git a/.gitignore b/.gitignore index a34f51213028..105829ac74b9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ *.pot *.py[co] docs/_build/ +tests/coverage_html/ +tests/.coverage diff --git a/.hgignore b/.hgignore index 765a29d09143..3dc253a3c10c 100644 --- a/.hgignore +++ b/.hgignore @@ -4,3 +4,5 @@ syntax:glob *.pot *.py[co] docs/_build/ +tests/coverage_html/ +tests/.coverage \ No newline at end of file diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index 9f5f9b0bef77..3e791c09a1e4 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -165,6 +165,26 @@ associated tests will be skipped. .. _gettext: http://www.gnu.org/software/gettext/manual/gettext.html .. _selenium: http://pypi.python.org/pypi/selenium +Code coverage +~~~~~~~~~~~~~ + +Contributors are encouraged to run coverage on the test suite to identify areas +that need additional tests. The coverage tool installation and use is described +in :ref:`testing code coverage`. + +To run coverage on the Django test suite using the standard test settings:: + + coverage run ./runtests.py --settings=test_sqlite + +After running coverage, generate the html report by running:: + + coverage html + +When running coverage for the Django tests, the included ``.coveragerc`` +settings file defines ``coverage_html`` as the output directory for the report +and also excludes several directories not relevant to the results +(test code or external code included in Django). + .. _contrib-apps: Contrib apps diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt index 3e9c048820a9..7ef33afe918c 100644 --- a/docs/topics/testing.txt +++ b/docs/topics/testing.txt @@ -575,6 +575,8 @@ to a faster hashing algorithm:: Don't forget to also include in :setting:`PASSWORD_HASHERS` any hashing algorithm used in fixtures, if any. +.. _topics-testing-code-coverage: + Integration with coverage.py ---------------------------- diff --git a/tests/.coveragerc b/tests/.coveragerc new file mode 100644 index 000000000000..b979e94c5828 --- /dev/null +++ b/tests/.coveragerc @@ -0,0 +1,5 @@ +[run] +omit = runtests,test_sqlite,regressiontests*,modeltests*,*/django/contrib/*/tests*,*/django/utils/unittest*,*/django/utils/simplejson*,*/django/utils/importlib.py,*/django/test/_doctest.py,*/django/core/servers/fastcgi.py,*/django/utils/autoreload.py,*/django/utils/dictconfig.py + +[html] +directory = coverage_html From cf17d5e2676f7ddf71ce0a78bfa0060da718924c Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 11 Oct 2012 06:47:29 -0400 Subject: [PATCH 125/367] [1.4.X] Fixed #14165 - Documented that TransactionMiddleware only applies to the default database. Backport of 938ee7cd36 from master --- docs/ref/middleware.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index b92fe2c0a192..60a376bd66bd 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -206,9 +206,9 @@ Transaction middleware .. class:: TransactionMiddleware -Binds commit and rollback to the request/response phase. If a view function -runs successfully, a commit is done. If it fails with an exception, a rollback -is done. +Binds commit and rollback of the default database to the request/response +phase. If a view function runs successfully, a commit is done. If it fails with +an exception, a rollback is done. The order of this middleware in the stack is important: middleware modules running outside of it run with commit-on-save - the default Django behavior. From a1d21c08774c87b8d2995aada5ad20c650ad7570 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 11 Oct 2012 18:04:25 -0400 Subject: [PATCH 126/367] [1.4.X] Fixed #16588 - Warned about field names that conflict with the model API Backport of dd0cbc6bdc from master --- docs/topics/db/models.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/topics/db/models.txt b/docs/topics/db/models.txt index 40e4a0a65b76..60d3a7016f29 100644 --- a/docs/topics/db/models.txt +++ b/docs/topics/db/models.txt @@ -84,7 +84,9 @@ Fields The most important part of a model -- and the only required part of a model -- is the list of database fields it defines. Fields are specified by class -attributes. +attributes. Be careful not to choose field names that conflict with the +:doc:`models API ` like ``clean``, ``save``, or +``delete``. Example:: From 8139a7990a682f282096167bcea35004cacf5559 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 11 Oct 2012 19:54:52 -0400 Subject: [PATCH 127/367] [1.4.X] Fixed #10936 - Noted that using SQLite for development is a good idea Backport of 470deb5cbb from master --- docs/topics/install.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/topics/install.txt b/docs/topics/install.txt index 7ac07101740c..1c847c92dce5 100644 --- a/docs/topics/install.txt +++ b/docs/topics/install.txt @@ -82,7 +82,12 @@ Get your database running If you plan to use Django's database API functionality, you'll need to make sure a database server is running. Django supports many different database servers and is officially supported with PostgreSQL_, MySQL_, Oracle_ and -SQLite_ (although SQLite doesn't require a separate server to be running). +SQLite_. + +It is common practice to use SQLite in a desktop development environment. +Unless you need database feature parity between your desktop development +environment and your deployment environment, using SQLite for development is +generally the simplest option as it doesn't require running a separate server. In addition to the officially supported databases, there are backends provided by 3rd parties that allow you to use other databases with Django: From e2dea54efe53fbeb66e684973ed6ad05f63969cc Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 12 Oct 2012 06:37:35 -0400 Subject: [PATCH 128/367] [1.4.X] Fixed #18256 - Added a potential pitfall when upgrading to MySQL 5.5.5 Backport of c870cb48cd from master --- docs/ref/databases.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index 1500f3f5c357..dba278bc4fc1 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -165,6 +165,16 @@ Since MySQL 5.5.5, the default storage engine is InnoDB_. This engine is fully transactional and supports foreign key references. It's probably the best choice at this point. +If you upgrade an existing project to MySQL 5.5.5 and subsequently add some +tables, ensure that your tables are using the same storage engine (i.e. MyISAM +vs. InnoDB). Specifically, if tables that have a ``ForeignKey`` between them +use different storage engines, you may see an error like the following when +running ``syncdb``:: + + _mysql_exceptions.OperationalError: ( + 1005, "Can't create table '\\db_name\\.#sql-4a8_ab' (errno: 150)" + ) + .. versionchanged:: 1.4 In previous versions of Django, fixtures with forward references (i.e. From d2891d1c0751d53c10f19672ac7d232b6e5088b0 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 12 Oct 2012 16:45:45 -0700 Subject: [PATCH 129/367] [1.4.x] Fixed #18996 - Clarified overriden model methods not called on bulk operations Backport of 443999a1eeea70e4deebcf31f8f845696be62c3d from master. --- docs/topics/db/models.txt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/topics/db/models.txt b/docs/topics/db/models.txt index 60d3a7016f29..a0b7416a4d24 100644 --- a/docs/topics/db/models.txt +++ b/docs/topics/db/models.txt @@ -761,7 +761,7 @@ built-in model methods, adding new arguments. If you use ``*args, **kwargs`` in your method definitions, you are guaranteed that your code will automatically support those arguments when they are added. -.. admonition:: Overriding Delete +.. admonition:: Overridden model methods are not called on bulk operations Note that the :meth:`~Model.delete()` method for an object is not necessarily called when :ref:`deleting objects in bulk using a @@ -769,6 +769,13 @@ code will automatically support those arguments when they are added. gets executed, you can use :data:`~django.db.models.signals.pre_delete` and/or :data:`~django.db.models.signals.post_delete` signals. + Unfortunately, there isn't a workaround when + :meth:`creating` or + :meth:`updating` objects in bulk, + since none of :meth:`~Model.save()`, + :data:`~django.db.models.signals.pre_save`, and + :data:`~django.db.models.signals.post_save` are called. + Executing custom SQL -------------------- From 4cdc416d039c7cfc04aa7bc156dd5a1c737375ed Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sat, 13 Oct 2012 11:02:18 +0200 Subject: [PATCH 130/367] [1.4.x] Fixed #19119 -- Corrected default date input formats in docs Thanks henrik@aisti.fi for the report. Backport of 10dc4797ea from master. --- docs/ref/forms/fields.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index 7ebd49592426..6047bcab9b28 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -411,7 +411,7 @@ For each field, we describe the default widget used if you don't specify Additionally, if you specify :setting:`USE_L10N=False` in your settings, the following will also be included in the default input formats:: - '%b %m %d', # 'Oct 25 2006' + '%b %d %Y', # 'Oct 25 2006' '%b %d, %Y', # 'Oct 25, 2006' '%d %b %Y', # '25 Oct 2006' '%d %b, %Y', # '25 Oct, 2006' From cc0478606a1d3dd804bc005a78e616550d3d0d41 Mon Sep 17 00:00:00 2001 From: Julien Phalip Date: Thu, 27 Sep 2012 20:34:45 -0700 Subject: [PATCH 131/367] [1.4.x] Fixed #18881 -- Made the context option in {% trans %} and {% blocktrans %} accept literals wrapped in single quotes. Thanks to lanyjie for the report. --- django/utils/translation/trans_real.py | 4 ++-- tests/regressiontests/i18n/commands/extraction.py | 15 +++++++++++++++ .../i18n/commands/templates/test.html | 5 +++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py index 82072b139743..427b3f6a830e 100644 --- a/django/utils/translation/trans_real.py +++ b/django/utils/translation/trans_real.py @@ -438,8 +438,8 @@ def blankout(src, char): return dot_re.sub(char, src) context_re = re.compile(r"""^\s+.*context\s+((?:"[^"]*?")|(?:'[^']*?'))\s*""") -inline_re = re.compile(r"""^\s*trans\s+((?:"[^"]*?")|(?:'[^']*?'))(\s+.*context\s+(?:"[^"]*?")|(?:'[^']*?'))?\s*""") -block_re = re.compile(r"""^\s*blocktrans(\s+.*context\s+(?:"[^"]*?")|(?:'[^']*?'))?(?:\s+|$)""") +inline_re = re.compile(r"""^\s*trans\s+((?:"[^"]*?")|(?:'[^']*?'))(\s+.*context\s+((?:"[^"]*?")|(?:'[^']*?')))?\s*""") +block_re = re.compile(r"""^\s*blocktrans(\s+.*context\s+((?:"[^"]*?")|(?:'[^']*?')))?(?:\s+|$)""") endblock_re = re.compile(r"""^\s*endblocktrans$""") plural_re = re.compile(r"""^\s*plural$""") constant_re = re.compile(r"""_\(((?:".*?")|(?:'.*?'))\)""") diff --git a/tests/regressiontests/i18n/commands/extraction.py b/tests/regressiontests/i18n/commands/extraction.py index 4eb7abc7f0f8..85a57e77d1ab 100644 --- a/tests/regressiontests/i18n/commands/extraction.py +++ b/tests/regressiontests/i18n/commands/extraction.py @@ -149,6 +149,21 @@ def test_template_message_context_extractor(self): self.assertTrue('msgctxt "Special blocktrans context #4"' in po_contents) self.assertTrue("Translatable literal #8d" in po_contents) + def test_context_in_single_quotes(self): + os.chdir(self.test_dir) + management.call_command('makemessages', locale=LOCALE, verbosity=0) + self.assertTrue(os.path.exists(self.PO_FILE)) + with open(self.PO_FILE, 'r') as fp: + po_contents = fp.read() + # {% trans %} + self.assertTrue('msgctxt "Context wrapped in double quotes"' in po_contents) + self.assertTrue('msgctxt "Context wrapped in single quotes"' in po_contents) + + # {% blocktrans %} + self.assertTrue('msgctxt "Special blocktrans context wrapped in double quotes"' in po_contents) + self.assertTrue('msgctxt "Special blocktrans context wrapped in single quotes"' in po_contents) + + class JavascriptExtractorTests(ExtractorTests): PO_FILE='locale/%s/LC_MESSAGES/djangojs.po' % LOCALE diff --git a/tests/regressiontests/i18n/commands/templates/test.html b/tests/regressiontests/i18n/commands/templates/test.html index 57893469840b..e7d7f3ca53b9 100644 --- a/tests/regressiontests/i18n/commands/templates/test.html +++ b/tests/regressiontests/i18n/commands/templates/test.html @@ -77,3 +77,8 @@ {% trans "Shouldn't double escape this sequence %% either" context "ctx1" %} {% trans "Looks like a str fmt spec %s but shouldn't be interpreted as such" %} {% trans "Looks like a str fmt spec % o but shouldn't be interpreted as such" %} + +{% trans "Translatable literal with context wrapped in single quotes" context 'Context wrapped in single quotes' as var %} +{% trans "Translatable literal with context wrapped in double quotes" context "Context wrapped in double quotes" as var %} +{% blocktrans context 'Special blocktrans context wrapped in single quotes' %}Translatable literal with context wrapped in single quotes{% endblocktrans %} +{% blocktrans context "Special blocktrans context wrapped in double quotes" %}Translatable literal with context wrapped in double quotes{% endblocktrans %} \ No newline at end of file From 81020708eae0a38b56cad86a043a78ffac9ba32a Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 15 Oct 2012 19:54:37 -0400 Subject: [PATCH 132/367] [1.4.X] Fixed #10936 - Tempered recommendation of SQLite - thanks Karen Tracey for the feedback. Backport of 9190d89829 from master --- docs/topics/install.txt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/topics/install.txt b/docs/topics/install.txt index 1c847c92dce5..351fb7126b14 100644 --- a/docs/topics/install.txt +++ b/docs/topics/install.txt @@ -84,10 +84,12 @@ sure a database server is running. Django supports many different database servers and is officially supported with PostgreSQL_, MySQL_, Oracle_ and SQLite_. -It is common practice to use SQLite in a desktop development environment. -Unless you need database feature parity between your desktop development -environment and your deployment environment, using SQLite for development is -generally the simplest option as it doesn't require running a separate server. +If you are developing a simple project or something you don't plan to deploy +in a production environment, SQLite is generally the simplest option as it +doesn't require running a separate server. However, SQLite has many differences +from other databases, so if you are working on something substantial, it's +recommended to develop with the same database as you plan on using in +production. In addition to the officially supported databases, there are backends provided by 3rd parties that allow you to use other databases with Django: From 6ebb6f918872c2a714ddb9808984e70daaa95cd8 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 16 Oct 2012 20:39:13 -0400 Subject: [PATCH 133/367] [1.4.X] Fixed #18548 - Clarified note regarding reusing model instances when form validation fails. Backport of fd02bcff4a from master --- docs/topics/forms/modelforms.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index a29da7074244..091073fb0c7f 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -206,7 +206,7 @@ of cleaning the model you pass to the ``ModelForm`` constructor. For instance, calling ``is_valid()`` on your form will convert any date fields on your model to actual date objects. If form validation fails, only some of the updates may be applied. For this reason, you'll probably want to avoid reusing the -model instance. +model instance passed to the form, especially if validation fails. The ``save()`` method From 33d11463a0b9258ac0c72fd856b6d42512616e8d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 16 Oct 2012 20:53:53 -0400 Subject: [PATCH 134/367] [1.4.x] Fixed a couple links that didn't backport cleanly --- docs/topics/http/urls.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/http/urls.txt b/docs/topics/http/urls.txt index 30ae2083133f..347b447292fa 100644 --- a/docs/topics/http/urls.txt +++ b/docs/topics/http/urls.txt @@ -56,7 +56,7 @@ algorithm the system follows to determine which Python code to execute: 4. Once one of the regexes matches, Django imports and calls the given view, which is a simple Python function (or a :doc:`class based view - `). The view gets passed an + `). The view gets passed an :class:`~django.http.HttpRequest` as its first argument and any values captured in the regex as remaining arguments. @@ -674,7 +674,7 @@ The style you use is up to you. Note that if you use this technique -- passing objects rather than strings -- the view prefix (as explained in "The view prefix" above) will have no effect. -Note that :doc:`class based views` must be +Note that :doc:`class based views` must be imported:: from mysite.views import ClassBasedView From 73991b0b325f07fc62179c3684ed921f141e69ce Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 17 Oct 2012 07:03:40 -0400 Subject: [PATCH 135/367] [1.4.X] Fixed #18473 - Fixed a suggestion that GZipMiddleware needs to be first in the list of middleware. Backport of 3e0857041b from master --- docs/ref/middleware.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index 60a376bd66bd..cb8f737ad263 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -93,8 +93,8 @@ GZip middleware Compresses content for browsers that understand GZip compression (all modern browsers). -It is suggested to place this first in the middleware list, so that the -compression of the response content is the last thing that happens. +This middleware should be placed before any other middleware that need to +read or write the response body so that compression happens afterward. It will NOT compress content if any of the following are true: From 92d3430f12171f16f566c9050c40feefb830a4a3 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Wed, 17 Oct 2012 14:38:33 -0700 Subject: [PATCH 136/367] Fixed a security issue related to password resets Full disclosure and new release are forthcoming backport from master --- django/contrib/auth/tests/urls.py | 1 + django/contrib/auth/tests/views.py | 37 ++++++++++++++++++++++++++++++ django/contrib/auth/views.py | 2 +- django/http/__init__.py | 5 ++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/django/contrib/auth/tests/urls.py b/django/contrib/auth/tests/urls.py index dbbd35ee88d2..8a683b965df4 100644 --- a/django/contrib/auth/tests/urls.py +++ b/django/contrib/auth/tests/urls.py @@ -51,6 +51,7 @@ def userpage(request): (r'^logout/next_page/$', 'django.contrib.auth.views.logout', dict(next_page='/somewhere/')), (r'^remote_user/$', remote_user_auth_view), (r'^password_reset_from_email/$', 'django.contrib.auth.views.password_reset', dict(from_email='staffmember@example.com')), + (r'^admin_password_reset/$', 'django.contrib.auth.views.password_reset', dict(is_admin_site=True)), (r'^login_required/$', login_required(password_reset)), (r'^login_required_login_url/$', login_required(password_reset, login_url='/somewhere/')), diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py index 72440864da54..1525f888fef8 100644 --- a/django/contrib/auth/tests/views.py +++ b/django/contrib/auth/tests/views.py @@ -7,6 +7,7 @@ from django.contrib.sites.models import Site, RequestSite from django.contrib.auth.models import User from django.core import mail +from django.core.exceptions import SuspiciousOperation from django.core.urlresolvers import reverse, NoReverseMatch from django.http import QueryDict from django.utils.encoding import force_unicode @@ -106,6 +107,42 @@ def test_email_found_custom_from(self): self.assertEqual(len(mail.outbox), 1) self.assertEqual("staffmember@example.com", mail.outbox[0].from_email) + def test_admin_reset(self): + "If the reset view is marked as being for admin, the HTTP_HOST header is used for a domain override." + response = self.client.post('/admin_password_reset/', + {'email': 'staffmember@example.com'}, + HTTP_HOST='adminsite.com' + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertTrue("http://adminsite.com" in mail.outbox[0].body) + self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email) + + def test_poisoned_http_host(self): + "Poisoned HTTP_HOST headers can't be used for reset emails" + # This attack is based on the way browsers handle URLs. The colon + # should be used to separate the port, but if the URL contains an @, + # the colon is interpreted as part of a username for login purposes, + # making 'evil.com' the request domain. Since HTTP_HOST is used to + # produce a meaningful reset URL, we need to be certain that the + # HTTP_HOST header isn't poisoned. This is done as a check when get_host() + # is invoked, but we check here as a practical consequence. + with self.assertRaises(SuspiciousOperation): + self.client.post('/password_reset/', + {'email': 'staffmember@example.com'}, + HTTP_HOST='www.example:dr.frankenstein@evil.tld' + ) + self.assertEqual(len(mail.outbox), 0) + + def test_poisoned_http_host_admin_site(self): + "Poisoned HTTP_HOST headers can't be used for reset emails on admin views" + with self.assertRaises(SuspiciousOperation): + self.client.post('/admin_password_reset/', + {'email': 'staffmember@example.com'}, + HTTP_HOST='www.example:dr.frankenstein@evil.tld' + ) + self.assertEqual(len(mail.outbox), 0) + def _test_confirm_start(self): # Start by creating the email response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'}) diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index c86ef535954f..ac880209082f 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -156,7 +156,7 @@ def password_reset(request, is_admin_site=False, 'request': request, } if is_admin_site: - opts = dict(opts, domain_override=request.META['HTTP_HOST']) + opts = dict(opts, domain_override=request.get_host()) form.save(**opts) return HttpResponseRedirect(post_reset_redirect) else: diff --git a/django/http/__init__.py b/django/http/__init__.py index 8af7228b4b30..98ec9966c490 100644 --- a/django/http/__init__.py +++ b/django/http/__init__.py @@ -212,6 +212,11 @@ def get_host(self): server_port = str(self.META['SERVER_PORT']) if server_port != (self.is_secure() and '443' or '80'): host = '%s:%s' % (host, server_port) + + # Disallow potentially poisoned hostnames. + if set(';/?@&=+$,').intersection(host): + raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host) + return host def get_full_path(self): From 58806ce1530305390f593cc78b66d77443c6e1b2 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Wed, 17 Oct 2012 14:57:58 -0700 Subject: [PATCH 137/367] Fixed an error in the set cookie documentation --- docs/ref/request-response.txt | 8 +++++--- docs/topics/http/sessions.txt | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 158598173316..d435822a8dc9 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -16,7 +16,8 @@ passing the :class:`HttpRequest` as the first argument to the view function. Each view is responsible for returning an :class:`HttpResponse` object. This document explains the APIs for :class:`HttpRequest` and -:class:`HttpResponse` objects. +:class:`HttpResponse` objects, which are defined in the :mod:`django.http` +module. HttpRequest objects =================== @@ -28,7 +29,8 @@ HttpRequest objects Attributes ---------- -All attributes except ``session`` should be considered read-only. +All attributes should be considered read-only, unless stated otherwise below. +``session`` is a notable exception. .. attribute:: HttpRequest.body @@ -648,7 +650,7 @@ 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, httponly=True) +.. method:: HttpResponse.set_cookie(key, value='', max_age=None, expires=None, path='/', domain=None, secure=None, httponly=False) .. versionchanged:: 1.3 diff --git a/docs/topics/http/sessions.txt b/docs/topics/http/sessions.txt index 467e702ff727..47bb7bb3c32d 100644 --- a/docs/topics/http/sessions.txt +++ b/docs/topics/http/sessions.txt @@ -516,6 +516,9 @@ 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.4 + The default value of the setting was changed from ``False`` to ``True``. + .. _HTTPOnly: https://www.owasp.org/index.php/HTTPOnly SESSION_COOKIE_NAME From 0f54fed0b6a94e981007cf9202d867fb1f9ba3ce Mon Sep 17 00:00:00 2001 From: James Bennett Date: Wed, 17 Oct 2012 17:15:49 -0500 Subject: [PATCH 138/367] [1.4.x] Bump version numbers for security release. --- docs/conf.py | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 2959b59dffe4..10d34ad7fc89 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.1' +version = '1.4.2' # The full version, including alpha/beta/rc tags. -release = '1.4.1' +release = '1.4.2' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index fc937cefed24..8c5c198f79d2 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.1.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.2.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 8c46ead92b9c6674cee524215f47a067d1940b31 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Wed, 17 Oct 2012 17:17:37 -0500 Subject: [PATCH 139/367] [1.4.x] Bump ALL the version numbers. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index ebfdb25a7f42..f4814c21435b 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 1, 'final', 0) +VERSION = (1, 4, 2, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 773a29295a8811fd018b3b30c6efa9266c5f540a Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 18 Oct 2012 11:18:25 -0700 Subject: [PATCH 140/367] Added missed poisoned host header test changes --- tests/regressiontests/requests/tests.py | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/regressiontests/requests/tests.py b/tests/regressiontests/requests/tests.py index b7a4788ec5b7..cf8fed0253cf 100644 --- a/tests/regressiontests/requests/tests.py +++ b/tests/regressiontests/requests/tests.py @@ -7,6 +7,7 @@ from django.conf import settings from django.core.handlers.modpython import ModPythonRequest +from django.core.exceptions import SuspiciousOperation from django.core.handlers.wsgi import WSGIRequest, LimitedStream from django.http import HttpRequest, HttpResponse, parse_cookie, build_request_repr, UnreadablePostError from django.test.utils import get_warnings_state, restore_warnings_state @@ -145,6 +146,38 @@ def test_http_get_host(self): } self.assertEqual(request.get_host(), 'internal.com:8042') + # Poisoned host headers are rejected as suspicious + legit_hosts = [ + 'example.com', + 'example.com:80', + '12.34.56.78', + '12.34.56.78:443', + '[2001:19f0:feee::dead:beef:cafe]', + '[2001:19f0:feee::dead:beef:cafe]:8080', + ] + + poisoned_hosts = [ + 'example.com@evil.tld', + 'example.com:dr.frankenstein@evil.tld', + 'example.com:someone@somestie.com:80', + 'example.com:80/badpath' + ] + + for host in legit_hosts: + request = HttpRequest() + request.META = { + 'HTTP_HOST': host, + } + request.get_host() + + for host in poisoned_hosts: + with self.assertRaises(SuspiciousOperation): + request = HttpRequest() + request.META = { + 'HTTP_HOST': host, + } + request.get_host() + finally: settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST @@ -189,6 +222,38 @@ def test_http_get_host_with_x_forwarded_host(self): } self.assertEqual(request.get_host(), 'internal.com:8042') + # Poisoned host headers are rejected as suspicious + legit_hosts = [ + 'example.com', + 'example.com:80', + '12.34.56.78', + '12.34.56.78:443', + '[2001:19f0:feee::dead:beef:cafe]', + '[2001:19f0:feee::dead:beef:cafe]:8080', + ] + + poisoned_hosts = [ + 'example.com@evil.tld', + 'example.com:dr.frankenstein@evil.tld', + 'example.com:dr.frankenstein@evil.tld:80', + 'example.com:80/badpath' + ] + + for host in legit_hosts: + request = HttpRequest() + request.META = { + 'HTTP_HOST': host, + } + request.get_host() + + for host in poisoned_hosts: + with self.assertRaises(SuspiciousOperation): + request = HttpRequest() + request.META = { + 'HTTP_HOST': host, + } + request.get_host() + finally: settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST From fd90a906333b569340e5507fec3f3a4d4c1d0f47 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 16 Oct 2012 16:12:52 -0400 Subject: [PATCH 141/367] [1.4.X] Fixed #18046 - Documented that an index is created by default for ForeignKeys; thanks jbauer for the suggestion. Backport of db598dd8a0 from master --- docs/ref/models/fields.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index c8b9db33f695..b186a461c7b8 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -972,6 +972,12 @@ need to use:: This sort of reference can be useful when resolving circular import dependencies between two applications. +A database index is automatically created on the ``ForeignKey``. You can +disable this by setting :attr:`~Field.db_index` to ``False``. You may want to +avoid the overhead of an index if you are creating a foreign key for +consistency rather than joins, or if you will be creating an alternative index +like a partial or multiple column index. + Database Representation ~~~~~~~~~~~~~~~~~~~~~~~ From 700717db1f7e3032cfd89ea80f37eb69bc54a188 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 19 Oct 2012 06:52:30 -0400 Subject: [PATCH 142/367] [1.4.X] Fixed #17388 - Noted in the custom model field docs that field methods need to handle None if the field may be null. Backport of 4cef9a09f9 from master --- docs/howto/custom-model-fields.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/howto/custom-model-fields.txt b/docs/howto/custom-model-fields.txt index 53fa4bd557b1..daaede8e15a0 100644 --- a/docs/howto/custom-model-fields.txt +++ b/docs/howto/custom-model-fields.txt @@ -447,6 +447,13 @@ called when it is created, you should be using `The SubfieldBase metaclass`_ mentioned earlier. Otherwise :meth:`.to_python` won't be called automatically. +.. warning:: + + If your custom field allows ``null=True``, any field method that takes + ``value`` as an argument, like :meth:`~Field.to_python` and + :meth:`~Field.get_prep_value`, should handle the case when ``value`` is + ``None``. + Converting Python objects to query values ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From e7685b87c13e87d429dd30065373d8dd2f20a2e4 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 18 Oct 2012 20:12:41 -0400 Subject: [PATCH 143/367] [1.4.X] Fixed #17006 - Documented ModelAdmin get_form() and get_formsets() Backport of eed4faf16f from master --- docs/ref/contrib/admin/index.txt | 41 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index d3154d6dd872..43446353dd16 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -314,7 +314,9 @@ subclass:: By default a ``ModelForm`` is dynamically created for your model. It is used to create the form presented on both the add/change pages. You can easily provide your own ``ModelForm`` to override any default form behavior - on the add/change pages. + on the add/change pages. Alternatively, you can customize the default + form rather than specifying an entirely new one by using the + :meth:`ModelAdmin.get_form` method. For an example see the section `Adding custom validation to the admin`_. @@ -380,7 +382,8 @@ subclass:: .. attribute:: ModelAdmin.inlines - See :class:`InlineModelAdmin` objects below. + See :class:`InlineModelAdmin` objects below as well as + :meth:`ModelAdmin.get_formsets`. .. attribute:: ModelAdmin.list_display @@ -1130,6 +1133,38 @@ templates used by the :class:`ModelAdmin` views: (r'^my_view/$', self.admin_site.admin_view(self.my_view, cacheable=True)) +.. method:: ModelAdmin.get_form(self, request, obj=None, **kwargs) + + Returns a :class:`~django.forms.ModelForm` class for use in the admin add + and change views, see :meth:`add_view` and :meth:`change_view`. + + If you wanted to hide a field from non-superusers, for example, you could + override ``get_form`` as follows:: + + class MyModelAdmin(admin.ModelAdmin): + def get_form(self, request, obj=None, **kwargs): + self.exclude = [] + if not request.user.is_superuser: + self.exclude.append('field_to_hide') + return super(MyModelAdmin, self).get_form(request, obj, **kwargs) + +.. method:: ModelAdmin.get_formsets(self, request, obj=None) + + Yields :class:`InlineModelAdmin`\s for use in admin add and change views. + + For example if you wanted to display a particular inline only in the change + view, you could override ``get_formsets`` as follows:: + + class MyModelAdmin(admin.ModelAdmin): + inlines = [MyInline, SomeOtherInline] + + def get_formsets(self, request, obj=None): + for inline in self.get_inline_instances(): + # hide MyInline in the add view + if isinstance(inline, MyInline) and obj is None: + continue + yield inline.get_formset(request, obj) + .. method:: ModelAdmin.formfield_for_foreignkey(self, db_field, request, **kwargs) The ``formfield_for_foreignkey`` method on a ``ModelAdmin`` allows you to @@ -1454,8 +1489,6 @@ The ``InlineModelAdmin`` class adds: through to ``inlineformset_factory`` when creating the formset for this inline. - .. _ref-contrib-admin-inline-extra: - .. attribute:: InlineModelAdmin.extra This controls the number of extra forms the formset will display in From 13bbe9161d38d3c0778577a3547fab2d181a8e5e Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 20 Oct 2012 09:57:51 -0400 Subject: [PATCH 144/367] [1.4.x] Fixed arguments for get_inline_instances; refs #17006. --- docs/ref/contrib/admin/index.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 43446353dd16..6fc88cc1359b 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -1159,7 +1159,7 @@ templates used by the :class:`ModelAdmin` views: inlines = [MyInline, SomeOtherInline] def get_formsets(self, request, obj=None): - for inline in self.get_inline_instances(): + for inline in self.get_inline_instances(request): # hide MyInline in the add view if isinstance(inline, MyInline) and obj is None: continue From 6c1c490f64bb5114570a45a523bec365f89f681d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 20 Oct 2012 15:21:19 -0400 Subject: [PATCH 145/367] [1.4.X] Fixed #13869 - Warned that QuerySet.iterator() doesn't affect DB driver caching; thanks jtiai for the suggestion. Backport of 2f722d9728 from master --- docs/ref/models/querysets.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index da0e3e66cedd..a32c9f50fd89 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -1451,6 +1451,16 @@ evaluated will force it to evaluate again, repeating the query. Also, use of ``iterator()`` causes previous ``prefetch_related()`` calls to be ignored since these two optimizations do not make sense together. +.. warning:: + + Some Python database drivers like ``psycopg2`` perform caching if using + client side cursors (instantiated with ``connection.cursor()`` and what + Django's ORM uses). Using ``iterator()`` does not affect caching at the + database driver level. To disable this caching, look at `server side + cursors`_. + +.. _server side cursors: http://initd.org/psycopg/docs/usage.html#server-side-cursors + latest ~~~~~~ From e86e4ce0bd0a538fcde0f9b0c4f26c6810621bb5 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 18 Oct 2012 09:55:14 -0700 Subject: [PATCH 146/367] Added 1.4.2 release notes --- docs/releases/1.4.2.txt | 43 ++++++++++++++++++++++++++++++++++++++++- docs/releases/index.txt | 2 +- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/releases/1.4.2.txt b/docs/releases/1.4.2.txt index 6f2e9aca2e31..07eec39764d0 100644 --- a/docs/releases/1.4.2.txt +++ b/docs/releases/1.4.2.txt @@ -2,13 +2,54 @@ Django 1.4.2 release notes ========================== -*TO BE RELEASED* +*October 17, 2012* This is the second security release in the Django 1.4 series. +Host header poisoning +--------------------- + +Some parts of Django -- independent of end-user-written applications -- make +use of full URLs, including domain name, which are generated from the HTTP Host +header. Some attacks against this are beyond Django's ability to control, and +require the web server to be properly configured; Django's documentation has +for some time contained notes advising users on such configuration. + +Django's own built-in parsing of the Host header is, however, still vulnerable, +as was reported to us recently. The Host header parsing in Django 1.3.3 and +Django 1.4.1 -- specifically, django.http.HttpRequest.get_host() -- was +incorrectly handling username/password information in the header. Thus, for +example, the following Host header would be accepted by Django when running on +"validsite.com":: + + Host: validsite.com:random@evilsite.com + +Using this, an attacker can cause parts of Django -- particularly the +password-reset mechanism -- to generate and display arbitrary URLs to users. + +To remedy this, the parsing in HttpRequest.get_host() is being modified; Host +headers which contain potentially dangerous content (such as username/password +pairs) now raise the exception django.core.exceptions.SuspiciousOperation + +Details of this issue were initially posted online as a `security advisory`_. + +.. _security advisory: https://www.djangoproject.com/weblog/2012/oct/17/security/ + Backwards incompatible changes ============================== * The newly introduced :class:`~django.db.models.GenericIPAddressField` constructor arguments have been adapted to match those of all other model fields. The first two keyword arguments are now verbose_name and name. + +Other bugfixes and changes +========================== + +* Subclass HTMLParser only for appropriate Python versions (#18239). +* Added batch_size argument to qs.bulk_create() (#17788). +* Fixed a small regression in the admin filters where wrongly formatted dates passed as url parameters caused an unhandled ValidationError (#18530). +* Fixed an endless loop bug when accessing permissions in templates (#18979) +* Fixed some Python 2.5 compatibility issues +* Fixed an issue with quoted filenames in Content-Disposition header (#19006) +* Made the context option in ``trans`` and ``blocktrans`` tags accept literals wrapped in single quotes (#18881). +* Numerous documentation improvements and fixes. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 3166cb4b96f6..0b465a6d80d2 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -20,7 +20,7 @@ Final releases .. toctree:: :maxdepth: 1 - .. 1.4.2 (uncomment on release) + 1.4.2 1.4.1 1.4 From ce168bb8994b0a7e032166e63a8b1cec034694d6 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Sun, 9 Sep 2012 12:13:42 -0600 Subject: [PATCH 147/367] [1.4.x] Fix an HTML-parser test that's failed in Python 2.6.8 since 5c79dd58. The problem description in #18239 asserted that http://bugs.python.org/issue670664 was fixed in Python 2.6.8, but based on http://bugs.python.org/issue670664#msg146770 it appears that's not correct; the fix was only applied in 2.7, 3.2, and Python trunk. Therefore we must use our patched HTMLParser subclass in all Python 2.6 versions. Backport of fcec904e4f from master. Fixes #19148. --- django/utils/html_parser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/django/utils/html_parser.py b/django/utils/html_parser.py index 444946151d0d..858668ca2096 100644 --- a/django/utils/html_parser.py +++ b/django/utils/html_parser.py @@ -5,8 +5,7 @@ current_version = sys.version_info use_workaround = ( - (current_version < (2, 6, 8)) or - (current_version >= (2, 7) and current_version < (2, 7, 3)) or + (current_version < (2, 7, 3)) or (current_version >= (3, 0) and current_version < (3, 2, 3)) ) From baf1f1dcde1f318fdb6514eaa8497fc958aa69d3 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 24 Oct 2012 16:30:23 -0400 Subject: [PATCH 148/367] [1.4.X] Fixed #9471 - Expanded ModelAdmin.raw_id_fields docs; thanks adroffne for the suggestion. Backport of da958eb209 from master --- docs/ref/contrib/admin/_images/raw_id_fields.png | Bin 0 -> 1871 bytes docs/ref/contrib/admin/index.txt | 8 ++++++++ 2 files changed, 8 insertions(+) create mode 100644 docs/ref/contrib/admin/_images/raw_id_fields.png diff --git a/docs/ref/contrib/admin/_images/raw_id_fields.png b/docs/ref/contrib/admin/_images/raw_id_fields.png new file mode 100644 index 0000000000000000000000000000000000000000..0774c40469146f085020e8e6df4dda27cc2309af GIT binary patch literal 1871 zcmV-V2e9~wP)RqI$ZC(hS#e-s_mUs;XLjz&^b$$ib{Psy{2?HUrsRz>#(?Qe#b6x!>on%x?C#&+hWg^W@301j8_Z5Ml?;WH$gIG!6ra5Q|_S5n>Sx zBtk5LfkcQ!c$3|LDM+W&O?DHIf2E|P_^C1m(l7jX!O*1h>jy3lSrK9p>>&|)Ff2oVArl)I-M>eB0`~1NTpH$Fqur-w{P$2>MAZSE-x>?_fp;i z$yhrZn@sV!<*U=Ca{vH>X3OL`GC2T%!%$mX<4SvCxb3TNLH6QXxiR1V`=i=}XRli! zI4bppywy)f390t28xJO^-^vW<062^v7Vi=%N>@bl9fsPX8pnL8Rex>RYU4h(_{Fu) zJSrf79%}ofroN?T*iG=na~3I7IdeGXGlx&QQkQ1T=KuH;hLAtCw6p+#LZL`bP99q# zl}ZZ>3sovrb91v#rMyq-ID6ZSo*4PuQ17cfh!IG=as`Be>v+{n<{titbBjtnx|%P z0X#z;&D!&W9-oee#Te_C?>v)H+D>^{=j_qEoRt(G7Z(>dchQFGwh=nu%|k!N;NW0P zOw3r{2mlmCSu7SFkC%{;;A<)GiKD4u2S0Qgp8x>xn6;bVC@3f>Q2zGsmnZ-L&a}jz zrA6=w0K$3wWj{5G2fzzY$)4*xf40vB1eZrR zZ%n+NzY&%gA1 z_*Z*37b;Yxl}(*Dr&tfci&Q z70nRZ@B9I|Z}r;+Vbgd7m(QU+^w`jqq3q7_L9EAW9v%xixP}Mpf|E2?-QM9X)!KVVFBaL_f}-W~xNZBjMjK7rjN1L@iFxT! zLHuw@*v)qRO%H(E-1=FUeZ0$KsII1e^;l`z*7MncE_D96566&>^*2 zef;=w0Pytz0C)S@gCCjw`o_>t8~<5ZRA-}sBZ`nDEq!&VOaR{>P7=cSUnrM9`n`3E&yzpcqfVcWw@*EMs;jG& zN~OVIXliPrX_}^Ki9|9oGNRY()6>&cD%FY=EAFWf6G&9I=1uj!YtDe~G~mviJLhF7 z)lr@a6#VRI!5TgwG!Pg@7LFxW5p#45^&h`}E^~d|6&C=EUeZ+j^&CHh5AJIRw>28SIk{zd zicBh%N@eN4`HyX~+l(H7?Ck8kyu7j3TefUzXlVGsP4Gsz0N}^1uhp*q{xa9KFU|zL z@W(~7H~{gg6W^#Nr)kK~^7Hd)n$~DE08mv`wQ}XkiQgaCKnf)5-u**B_sCWr^VyT+ znu_G$$!-YpyA2yQ5CpM%_wKT?vLAdi!Qf*cA2gj#hyM>bh57aR;&(`dSOf!!5R2fX zj$hzob Date: Sun, 28 Oct 2012 16:47:07 +0200 Subject: [PATCH 149/367] [1.4.x] Fixed #18823 -- Ensured m2m.clear() works when using through+to_field There was a potential data-loss issue involved -- when clearing instance's m2m assignments it was possible some other instance's m2m data was deleted instead. This commit also improved None handling for to_field cases. Backpatch of 611c4d6f1c24763e5e6e331a5dcf9b610288aaa8 --- django/db/models/fields/related.py | 45 ++++++++-- .../m2m_through_regress/models.py | 10 +-- .../m2m_through_regress/tests.py | 90 ++++++++++++++++++- 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 7034d348675e..28e8e06c1866 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -531,9 +531,31 @@ def __init__(self, model=None, query_field_name=None, instance=None, symmetrical self.reverse = reverse self.through = through self.prefetch_cache_name = prefetch_cache_name - self._pk_val = self.instance.pk - if self._pk_val is None: - raise ValueError("%r instance needs to have a primary key value before a many-to-many relationship can be used." % instance.__class__.__name__) + self._fk_val = self._get_fk_val(instance, source_field_name) + if self._fk_val is None: + raise ValueError('"%r" needs to have a value for field "%s" before ' + 'this many-to-many relationship can be used.' % + (instance, source_field_name)) + # Even if this relation is not to pk, we require still pk value. + # The wish is that the instance has been already saved to DB, + # although having a pk value isn't a guarantee of that. + if instance.pk is None: + raise ValueError("%r instance needs to have a primary key value before " + "a many-to-many relationship can be used." % + instance.__class__.__name__) + + + def _get_fk_val(self, obj, field_name): + """ + Returns the correct value for this relationship's foreign key. This + might be something else than pk value when to_field is used. + """ + fk = self.through._meta.get_field(field_name) + if fk.rel.field_name and fk.rel.field_name != fk.rel.to._meta.pk.attname: + attname = fk.rel.get_related_field().get_attname() + return fk.get_prep_lookup('exact', getattr(obj, attname)) + else: + return obj.pk def get_query_set(self): try: @@ -635,7 +657,11 @@ def _add_items(self, source_field_name, target_field_name, *objs): if not router.allow_relation(obj, self.instance): raise ValueError('Cannot add "%r": instance is on database "%s", value is on database "%s"' % (obj, self.instance._state.db, obj._state.db)) - new_ids.add(obj.pk) + fk_val = self._get_fk_val(obj, target_field_name) + if fk_val is None: + raise ValueError('Cannot add "%r": the value for field "%s" is None' % + (obj, target_field_name)) + new_ids.add(self._get_fk_val(obj, target_field_name)) elif isinstance(obj, Model): raise TypeError("'%s' instance expected, got %r" % (self.model._meta.object_name, obj)) else: @@ -643,7 +669,7 @@ def _add_items(self, source_field_name, target_field_name, *objs): db = router.db_for_write(self.through, instance=self.instance) vals = self.through._default_manager.using(db).values_list(target_field_name, flat=True) vals = vals.filter(**{ - source_field_name: self._pk_val, + source_field_name: self._fk_val, '%s__in' % target_field_name: new_ids, }) new_ids = new_ids - set(vals) @@ -657,11 +683,12 @@ def _add_items(self, source_field_name, target_field_name, *objs): # Add the ones that aren't there already self.through._default_manager.using(db).bulk_create([ self.through(**{ - '%s_id' % source_field_name: self._pk_val, + '%s_id' % source_field_name: self._fk_val, '%s_id' % target_field_name: obj_id, }) for obj_id in new_ids ]) + if self.reverse or source_field_name == self.source_field_name: # Don't send the signal when we are inserting the # duplicate data row for symmetrical reverse entries. @@ -680,7 +707,7 @@ def _remove_items(self, source_field_name, target_field_name, *objs): old_ids = set() for obj in objs: if isinstance(obj, self.model): - old_ids.add(obj.pk) + old_ids.add(self._get_fk_val(obj, target_field_name)) else: old_ids.add(obj) # Work out what DB we're operating on @@ -694,7 +721,7 @@ def _remove_items(self, source_field_name, target_field_name, *objs): model=self.model, pk_set=old_ids, using=db) # Remove the specified objects from the join table self.through._default_manager.using(db).filter(**{ - source_field_name: self._pk_val, + source_field_name: self._fk_val, '%s__in' % target_field_name: old_ids }).delete() if self.reverse or source_field_name == self.source_field_name: @@ -714,7 +741,7 @@ def _clear_items(self, source_field_name): instance=self.instance, reverse=self.reverse, model=self.model, pk_set=None, using=db) self.through._default_manager.using(db).filter(**{ - source_field_name: self._pk_val + source_field_name: self._fk_val }).delete() if self.reverse or source_field_name == self.source_field_name: # Don't send the signal when we are clearing the diff --git a/tests/regressiontests/m2m_through_regress/models.py b/tests/regressiontests/m2m_through_regress/models.py index ff71348931ff..9cd1475fd5bb 100644 --- a/tests/regressiontests/m2m_through_regress/models.py +++ b/tests/regressiontests/m2m_through_regress/models.py @@ -54,17 +54,17 @@ class B(models.Model): # Using to_field on the through model class Car(models.Model): - make = models.CharField(max_length=20, unique=True) + make = models.CharField(max_length=20, unique=True, null=True) drivers = models.ManyToManyField('Driver', through='CarDriver') def __unicode__(self, ): - return self.make + return "%s" % self.make class Driver(models.Model): - name = models.CharField(max_length=20, unique=True) + name = models.CharField(max_length=20, unique=True, null=True) - def __unicode__(self, ): - return self.name + def __unicode__(self): + return "%s" % self.name class CarDriver(models.Model): car = models.ForeignKey('Car', to_field='make') diff --git a/tests/regressiontests/m2m_through_regress/tests.py b/tests/regressiontests/m2m_through_regress/tests.py index 1c188d05c3a9..6da089fcb672 100644 --- a/tests/regressiontests/m2m_through_regress/tests.py +++ b/tests/regressiontests/m2m_through_regress/tests.py @@ -127,18 +127,104 @@ def setUp(self): self.car = Car.objects.create(make="Toyota") self.driver = Driver.objects.create(name="Ryan Briscoe") CarDriver.objects.create(car=self.car, driver=self.driver) + # We are testing if wrong objects get deleted due to using wrong + # field value in m2m queries. So, it is essential that the pk + # numberings do not match. + # Create one intentionally unused driver to mix up the autonumbering + self.unused_driver = Driver.objects.create(name="Barney Gumble") + # And two intentionally unused cars. + self.unused_car1 = Car.objects.create(make="Trabant") + self.unused_car2 = Car.objects.create(make="Wartburg") def test_to_field(self): self.assertQuerysetEqual( self.car.drivers.all(), [""] - ) + ) def test_to_field_reverse(self): self.assertQuerysetEqual( self.driver.car_set.all(), [""] - ) + ) + + def test_to_field_clear_reverse(self): + self.driver.car_set.clear() + self.assertQuerysetEqual( + self.driver.car_set.all(),[]) + + def test_to_field_clear(self): + self.car.drivers.clear() + self.assertQuerysetEqual( + self.car.drivers.all(),[]) + + # Low level tests for _add_items and _remove_items. We test these methods + # because .add/.remove aren't available for m2m fields with through, but + # through is the only way to set to_field currently. We do want to make + # sure these methods are ready if the ability to use .add or .remove with + # to_field relations is added some day. + def test_add(self): + self.assertQuerysetEqual( + self.car.drivers.all(), + [""] + ) + # Yikes - barney is going to drive... + self.car.drivers._add_items('car', 'driver', self.unused_driver) + self.assertQuerysetEqual( + self.car.drivers.all(), + ["", ""] + ) + + def test_add_null(self): + nullcar = Car.objects.create(make=None) + with self.assertRaises(ValueError): + nullcar.drivers._add_items('car', 'driver', self.unused_driver) + + def test_add_related_null(self): + nulldriver = Driver.objects.create(name=None) + with self.assertRaises(ValueError): + self.car.drivers._add_items('car', 'driver', nulldriver) + + def test_add_reverse(self): + car2 = Car.objects.create(make="Honda") + self.assertQuerysetEqual( + self.driver.car_set.all(), + [""] + ) + self.driver.car_set._add_items('driver', 'car', car2) + self.assertQuerysetEqual( + self.driver.car_set.all(), + ["", ""] + ) + + def test_add_null_reverse(self): + nullcar = Car.objects.create(make=None) + with self.assertRaises(ValueError): + self.driver.car_set._add_items('driver', 'car', nullcar) + + def test_add_null_reverse_related(self): + nulldriver = Driver.objects.create(name=None) + with self.assertRaises(ValueError): + nulldriver.car_set._add_items('driver', 'car', self.car) + + def test_remove(self): + self.assertQuerysetEqual( + self.car.drivers.all(), + [""] + ) + self.car.drivers._remove_items('car', 'driver', self.driver) + self.assertQuerysetEqual( + self.car.drivers.all(),[]) + + def test_remove_reverse(self): + self.assertQuerysetEqual( + self.driver.car_set.all(), + [""] + ) + self.driver.car_set._remove_items('driver', 'car', self.car) + self.assertQuerysetEqual( + self.driver.car_set.all(),[]) + class ThroughLoadDataTestCase(TestCase): fixtures = ["m2m_through"] From ad2d57a2ccb6316001205739090a2a1d79453207 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Mon, 29 Oct 2012 17:26:10 +0100 Subject: [PATCH 150/367] [1.4.x] Fixed #19172 -- Isolated poisoned_http_host tests from 500 handlers Thanks bernardofontes for the report. Backport of b774c5993 from master. --- django/contrib/auth/tests/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py index 1525f888fef8..d295bb8c1082 100644 --- a/django/contrib/auth/tests/views.py +++ b/django/contrib/auth/tests/views.py @@ -118,6 +118,8 @@ def test_admin_reset(self): self.assertTrue("http://adminsite.com" in mail.outbox[0].body) self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email) + # Skip any 500 handler action (like sending more mail...) + @override_settings(DEBUG_PROPAGATE_EXCEPTIONS=True) def test_poisoned_http_host(self): "Poisoned HTTP_HOST headers can't be used for reset emails" # This attack is based on the way browsers handle URLs. The colon @@ -134,6 +136,8 @@ def test_poisoned_http_host(self): ) self.assertEqual(len(mail.outbox), 0) + # Skip any 500 handler action (like sending more mail...) + @override_settings(DEBUG_PROPAGATE_EXCEPTIONS=True) def test_poisoned_http_host_admin_site(self): "Poisoned HTTP_HOST headers can't be used for reset emails on admin views" with self.assertRaises(SuspiciousOperation): From 2733253633b8fa7663de2d95a356b92556cef551 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Mon, 29 Oct 2012 21:39:12 +0100 Subject: [PATCH 151/367] [1.4.x] Fixed #19208 -- Docs for mod_wsgi daemon mode Thanks Graham Dumpleton for the patch. Backport of bc00075 from master. --- docs/howto/deployment/wsgi/modwsgi.txt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/howto/deployment/wsgi/modwsgi.txt b/docs/howto/deployment/wsgi/modwsgi.txt index 1aa40096970c..d669f6d85228 100644 --- a/docs/howto/deployment/wsgi/modwsgi.txt +++ b/docs/howto/deployment/wsgi/modwsgi.txt @@ -75,12 +75,20 @@ Using mod_wsgi daemon mode ========================== "Daemon mode" is the recommended mode for running mod_wsgi (on non-Windows -platforms). See the `official mod_wsgi documentation`_ for details on setting -up daemon mode. The only change required to the above configuration if you use -daemon mode is that you can't use ``WSGIPythonPath``; instead you should use -the ``python-path`` option to ``WSGIDaemonProcess``, for example:: +platforms). To create the required daemon process group and delegate the +Django instance to run in it, you will need to add appropriate +``WSGIDaemonProcess`` and ``WSGIProcessGroup`` directives. A further change +required to the above configuration if you use daemon mode is that you can't +use ``WSGIPythonPath``; instead you should use the ``python-path`` option to +``WSGIDaemonProcess``, for example:: WSGIDaemonProcess example.com python-path=/path/to/mysite.com:/path/to/venv/lib/python2.7/site-packages + WSGIProcessGroup example.com + +See the official mod_wsgi documentation for `details on setting up daemon +mode`_. + +.. _details on setting up daemon mode: http://code.google.com/p/modwsgi/wiki/QuickConfigurationGuide#Delegation_To_Daemon_Process .. _serving-files: From f8c005b4ec618a40b5ccd573c246a0b6d2960844 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Fri, 2 Nov 2012 09:29:55 +0100 Subject: [PATCH 152/367] Fixed #19225 -- Typo in shortcuts docs. Thanks SunPowered for the report. --- docs/topics/http/shortcuts.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/http/shortcuts.txt b/docs/topics/http/shortcuts.txt index cce0bb45c128..678ce031fdc9 100644 --- a/docs/topics/http/shortcuts.txt +++ b/docs/topics/http/shortcuts.txt @@ -4,7 +4,7 @@ Django shortcut functions .. module:: django.shortcuts :synopsis: - Convenience shortcuts that spam multiple levels of Django's MVC stack. + Convenience shortcuts that span multiple levels of Django's MVC stack. .. index:: shortcuts From fdb855e7b23d9080f87ed13560bbe383271796cb Mon Sep 17 00:00:00 2001 From: Nicolas Ippolito Date: Mon, 12 Nov 2012 22:15:41 +0100 Subject: [PATCH 153/367] [1.4.X] Typo in comments doc Backport of 17b14d4819 from master --- docs/ref/contrib/comments/index.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/contrib/comments/index.txt b/docs/ref/contrib/comments/index.txt index 61e7bd9f4691..2b8362590fa7 100644 --- a/docs/ref/contrib/comments/index.txt +++ b/docs/ref/contrib/comments/index.txt @@ -209,7 +209,7 @@ default version of which is included with Django. Rendering a custom comment form ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you want more control over the look and feel of the comment form, you use use +If you want more control over the look and feel of the comment form, you may use :ttag:`get_comment_form` to get a :doc:`form object ` that you can use in the template:: From 25e041f270bec5a69e525d47f18a73fe917d1787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Thu, 15 Nov 2012 14:23:02 +0200 Subject: [PATCH 154/367] [1.4.x] Fixed #19058 -- Fixed Oracle GIS crash The problem is the same as in #10888 which was reintroduced when bulk_insert was added. Thanks to Jani Tiainen for report, patch and also testing the final patch on Oracle GIS. Backpatch of 92d7f541da8b59520c833b19fbba52d3ecef2428 --- .../gis/db/backends/oracle/compiler.py | 24 +------------------ .../gis/db/backends/oracle/operations.py | 10 ++++++++ django/db/backends/__init__.py | 6 +++++ django/db/models/sql/compiler.py | 2 ++ 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/django/contrib/gis/db/backends/oracle/compiler.py b/django/contrib/gis/db/backends/oracle/compiler.py index f0eb5cad006f..98da0163ba06 100644 --- a/django/contrib/gis/db/backends/oracle/compiler.py +++ b/django/contrib/gis/db/backends/oracle/compiler.py @@ -7,29 +7,7 @@ class GeoSQLCompiler(BaseGeoSQLCompiler, SQLCompiler): pass class SQLInsertCompiler(compiler.SQLInsertCompiler, GeoSQLCompiler): - def placeholder(self, field, val): - if field is None: - # A field value of None means the value is raw. - return val - elif hasattr(field, 'get_placeholder'): - # Some fields (e.g. geo fields) need special munging before - # they can be inserted. - ph = field.get_placeholder(val, self.connection) - if ph == 'NULL': - # If the placeholder returned is 'NULL', then we need to - # to remove None from the Query parameters. Specifically, - # cx_Oracle will assume a CHAR type when a placeholder ('%s') - # is used for columns of MDSYS.SDO_GEOMETRY. Thus, we use - # 'NULL' for the value, and remove None from the query params. - # See also #10888. - param_idx = self.query.columns.index(field.column) - params = list(self.query.params) - params.pop(param_idx) - self.query.params = tuple(params) - return ph - else: - # Return the common case for the placeholder - return '%s' + pass class SQLDeleteCompiler(compiler.SQLDeleteCompiler, GeoSQLCompiler): pass diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py index a2374bb80866..f09bd738f346 100644 --- a/django/contrib/gis/db/backends/oracle/operations.py +++ b/django/contrib/gis/db/backends/oracle/operations.py @@ -9,6 +9,7 @@ """ import re from decimal import Decimal +from itertools import izip from django.db.backends.oracle.base import DatabaseOperations from django.contrib.gis.db.backends.base import BaseSpatialOperations @@ -287,3 +288,12 @@ def geometry_columns(self): def spatial_ref_sys(self): from django.contrib.gis.db.backends.oracle.models import SpatialRefSys return SpatialRefSys + + def modify_insert_params(self, placeholders, params): + """Drop out insert parameters for NULL placeholder. Needed for Oracle Spatial + backend due to #10888 + """ + # This code doesn't work for bulk insert cases. + assert len(placeholders) == 1 + return [[param for pholder,param + in izip(placeholders[0], params[0]) if pholder != 'NULL'], ] diff --git a/django/db/backends/__init__.py b/django/db/backends/__init__.py index a82d6f56a182..14e9c4abeb44 100644 --- a/django/db/backends/__init__.py +++ b/django/db/backends/__init__.py @@ -874,6 +874,12 @@ def combine_expression(self, connector, sub_expressions): conn = ' %s ' % connector return conn.join(sub_expressions) + def modify_insert_params(self, placeholders, params): + """Allow modification of insert parameters. Needed for Oracle Spatial + backend due to #10888. + """ + return params + class BaseDatabaseIntrospection(object): """ This class encapsulates all backend-specific introspection utilities diff --git a/django/db/models/sql/compiler.py b/django/db/models/sql/compiler.py index 6c516e2b21de..2b1c24589a9c 100644 --- a/django/db/models/sql/compiler.py +++ b/django/db/models/sql/compiler.py @@ -885,6 +885,8 @@ def as_sql(self): [self.placeholder(field, v) for field, v in izip(fields, val)] for val in values ] + # Oracle Spatial needs to remove some values due to #10888 + params = self.connection.ops.modify_insert_params(placeholders, params) if self.return_id and self.connection.features.can_return_id_from_insert: params = params[0] col = "%s.%s" % (qn(opts.db_table), qn(opts.pk.column)) From 06c14a63a2c27e5eddfbd6f7b35bcb1d0fc2882b Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 23 Oct 2012 07:02:48 -0400 Subject: [PATCH 155/367] [1.4.X] Fixed #13997 - Added an example of constructing a MultiWidget and documented the value_from_datadict method. Backport of 04775b4598 from master --- docs/ref/forms/widgets.txt | 192 +++++++++++++++++++++++++------------ 1 file changed, 129 insertions(+), 63 deletions(-) diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index 037efc8d39dc..60ba00c0ca7d 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -213,38 +213,49 @@ foundation for custom widgets. The 'value' given is not guaranteed to be valid input, therefore subclass implementations should program defensively. + .. method:: value_from_datadict(self, data, files, name) + + Given a dictionary of data and this widget's name, returns the value + of this widget. Returns ``None`` if a value wasn't provided. + .. class:: MultiWidget(widgets, attrs=None) A widget that is composed of multiple widgets. :class:`~django.forms.widgets.MultiWidget` works hand in hand with the :class:`~django.forms.MultiValueField`. - .. method:: render(name, value, attrs=None) + :class:`MultiWidget` has one required argument: - Argument `value` is handled differently in this method from the - subclasses of :class:`~Widget`. + .. attribute:: MultiWidget.widgets - If `value` is a list, output of :meth:`~MultiWidget.render` will be a - concatenation of rendered child widgets. If `value` is not a list, it - will be first processed by the method :meth:`~MultiWidget.decompress()` - to create the list and then processed as above. + An iterable containing the widgets needed. - Unlike in the single value widgets, method :meth:`~MultiWidget.render` - need not be implemented in the subclasses. + And one required method: .. method:: decompress(value) - Returns a list of "decompressed" values for the given value of the - multi-value field that makes use of the widget. The input value can be - assumed as valid, but not necessarily non-empty. + This method takes a single "compressed" value from the field and + returns a list of "decompressed" values. The input value can be + assumed valid, but not necessarily non-empty. This method **must be implemented** by the subclass, and since the value may be empty, the implementation must be defensive. The rationale behind "decompression" is that it is necessary to "split" - the combined value of the form field into the values of the individual - field encapsulated within the multi-value field (e.g. when displaying - the partially or fully filled-out form). + the combined value of the form field into the values for each widget. + + An example of this is how :class:`SplitDateTimeWidget` turns a + :class:`datetime` value into a list with date and time split into two + separate values:: + + class SplitDateTimeWidget(MultiWidget): + + # ... + + def decompress(self, value): + if value: + return [value.date(), value.time().replace(microsecond=0)] + return [None, None] .. tip:: @@ -253,6 +264,109 @@ foundation for custom widgets. with the opposite responsibility - to combine cleaned values of all member fields into one. + Other methods that may be useful to override include: + + .. method:: render(name, value, attrs=None) + + Argument ``value`` is handled differently in this method from the + subclasses of :class:`~Widget` because it has to figure out how to + split a single value for display in multiple widgets. + + The ``value`` argument used when rendering can be one of two things: + + * A ``list``. + * A single value (e.g., a string) that is the "compressed" representation + of a ``list`` of values. + + If `value` is a list, output of :meth:`~MultiWidget.render` will be a + concatenation of rendered child widgets. If `value` is not a list, it + will be first processed by the method :meth:`~MultiWidget.decompress()` + to create the list and then processed as above. + + In the second case -- i.e., if the value is *not* a list -- + ``render()`` will first decompress the value into a ``list`` before + rendering it. It does so by calling the ``decompress()`` method, which + :class:`MultiWidget`'s subclasses must implement (see above). + + When ``render()`` executes its HTML rendering, each value in the list + is rendered with the corresponding widget -- the first value is + rendered in the first widget, the second value is rendered in the + second widget, etc. + + Unlike in the single value widgets, method :meth:`~MultiWidget.render` + need not be implemented in the subclasses. + + .. method:: format_output(rendered_widgets) + + Given a list of rendered widgets (as strings), returns a Unicode string + representing the HTML for the whole lot. + + This hook allows you to format the HTML design of the widgets any way + you'd like. + + Here's an example widget which subclasses :class:`MultiWidget` to display + a date with the day, month, and year in different select boxes. This widget + is intended to be used with a :class:`~django.forms.DateField` rather than + a :class:`~django.forms.MultiValueField`, thus we have implemented + :meth:`~Widget.value_from_datadict`:: + + from datetime import date + from django.forms import widgets + + class DateSelectorWidget(widgets.MultiWidget): + def __init__(self, attrs=None): + # create choices for days, months, years + # example below, the rest snipped for brevity. + years = [(year, year) for year in (2011, 2012, 2013)] + _widgets = ( + widgets.Select(attrs=attrs, choices=days), + widgets.Select(attrs=attrs, choices=months), + widgets.Select(attrs=attrs, choices=years), + ) + super(DateSelectorWidget, self).__init__(_widgets, attrs) + + def decompress(self, value): + if value: + return [value.day, value.month, value.year] + return [None, None, None] + + def format_output(self, rendered_widgets): + return u''.join(rendered_widgets) + + def value_from_datadict(self, data, files, name): + datelist = [ + widget.value_from_datadict(data, files, name + '_%s' % i) + for i, widget in enumerate(self.widgets)] + try: + D = date(day=int(datelist[0]), month=int(datelist[1]), + year=int(datelist[2])) + except ValueError: + return '' + else: + return str(D) + + The constructor creates several :class:`Select` widgets in a tuple. The + ``super`` class uses this tuple to setup the widget. + + The :meth:`~MultiWidget.format_output` method is fairly vanilla here (in + fact, it's the same as what's been implemented as the default for + ``MultiWidget``), but the idea is that you could add custom HTML between + the widgets should you wish. + + The required method :meth:`~MultiWidget.decompress` breaks up a + ``datetime.date`` value into the day, month, and year values corresponding + to each widget. Note how the method handles the case where ``value`` is + ``None``. + + The default implementation of :meth:`~Widget.value_from_datadict` returns + a list of values corresponding to each ``Widget``. This is appropriate + when using a ``MultiWidget`` with a :class:`~django.forms.MultiValueField`, + but since we want to use this widget with a :class:`~django.forms.DateField` + which takes a single value, we have overridden this method to combine the + data of all the subwidgets into a ``datetime.date``. The method extracts + data from the ``POST`` dictionary and constructs and validates the date. + If it is valid, we return the string, otherwise, we return an empty string + which will cause ``form.is_valid`` to return ``False``. .. _built-in widgets: @@ -554,54 +668,6 @@ Composite widgets :attr:`~Field.choices` attribute. If it does, it will override anything you set here when the attribute is updated on the :class:`Field`. -``MultiWidget`` -~~~~~~~~~~~~~~~ - -.. class:: MultiWidget - - Wrapper around multiple other widgets. You'll probably want to use this - class with :class:`MultiValueField`. - - Its ``render()`` method is different than other widgets', because it has to - figure out how to split a single value for display in multiple widgets. - - Subclasses may implement ``format_output``, which takes the list of - rendered widgets and returns a string of HTML that formats them any way - you'd like. - - The ``value`` argument used when rendering can be one of two things: - - * A ``list``. - * A single value (e.g., a string) that is the "compressed" representation - of a ``list`` of values. - - In the second case -- i.e., if the value is *not* a list -- ``render()`` - will first decompress the value into a ``list`` before rendering it. It - does so by calling the ``decompress()`` method, which - :class:`MultiWidget`'s subclasses must implement. This method takes a - single "compressed" value and returns a ``list``. An example of this is how - :class:`SplitDateTimeWidget` turns a :class:`datetime` value into a list - with date and time split into two seperate values:: - - class SplitDateTimeWidget(MultiWidget): - - # ... - - def decompress(self, value): - if value: - return [value.date(), value.time().replace(microsecond=0)] - return [None, None] - - When ``render()`` executes its HTML rendering, each value in the list is - rendered with the corresponding widget -- the first value is rendered in - the first widget, the second value is rendered in the second widget, etc. - - :class:`MultiWidget` has one required argument: - - .. attribute:: MultiWidget.widgets - - An iterable containing the widgets needed. - ``SplitDateTimeWidget`` ~~~~~~~~~~~~~~~~~~~~~~~ From 9003d6fece7e0f86be008e189abf18be44fe9b93 Mon Sep 17 00:00:00 2001 From: Luke Plant Date: Tue, 20 Nov 2012 23:00:20 +0000 Subject: [PATCH 156/367] [1.4.x] Corrected docs about default value of MESSAGE_STORAGE Backport of a32f30c79c1be8e088917bced0f97760a92045ef from master --- docs/ref/contrib/messages.txt | 2 +- docs/ref/settings.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/contrib/messages.txt b/docs/ref/contrib/messages.txt index 223a1628304f..322764591546 100644 --- a/docs/ref/contrib/messages.txt +++ b/docs/ref/contrib/messages.txt @@ -345,7 +345,7 @@ This sets the minimum message that will be saved in the message storage. See MESSAGE_STORAGE --------------- -Default: ``'django.contrib.messages.storage.user_messages.FallbackStorage'`` +Default: ``'django.contrib.messages.storage.fallback.FallbackStorage'`` Controls where Django stores message data. Valid values are: diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 8845b170c6a3..34664822b5a4 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1439,7 +1439,7 @@ MESSAGE_STORAGE .. versionadded:: 1.2 -Default: ``'django.contrib.messages.storage.user_messages.LegacyFallbackStorage'`` +Default: ``'django.contrib.messages.storage.fallback.FallbackStorage'`` Controls where Django stores message data. See the :doc:`messages documentation ` for more details. From 19710955e4761c4ff23f64f64c07312da26d0d8c Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Thu, 15 Nov 2012 21:17:58 -0800 Subject: [PATCH 157/367] [1.4.x] Added examples of using startproject/app with URLs thanks to Brent O'Connor for the idea and intial docs --- docs/ref/django-admin.txt | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index eacd069abf02..0c9461110a09 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -940,14 +940,19 @@ either the path to a directory with the app template file, or a path to a compressed file (``.tar.gz``, ``.tar.bz2``, ``.tgz``, ``.tbz``, ``.zip``) containing the app template files. +For example, this would look for an app template in the given directory when +creating the ``myapp`` app:: + + django-admin.py startapp --template=/Users/jezdez/Code/my_app_template myapp + Django will also accept URLs (``http``, ``https``, ``ftp``) to compressed archives with the app template files, downloading and extracting them on the fly. -For example, this would look for an app template in the given directory when -creating the ``myapp`` app:: +For example, taking advantage of Github's feature to expose repositories as +zip files, you can use a URL like:: - django-admin.py startapp --template=/Users/jezdez/Code/my_app_template myapp + django-admin.py startapp --template=https://github.com/githubuser/django-app-template/archive/master.zip myapp .. versionadded:: 1.4 @@ -1015,6 +1020,15 @@ when creating the ``myproject`` project:: django-admin.py startproject --template=/Users/jezdez/Code/my_project_template myproject +Django will also accept URLs (``http``, ``https``, ``ftp``) to compressed +archives with the project template files, downloading and extracting them on the +fly. + +For example, taking advantage of Github's feature to expose repositories as +zip files, you can use a URL like:: + + django-admin.py startproject --template=https://github.com/githubuser/django-project-template/archive/master.zip myproject + When Django copies the project template files, it also renders certain files through the template engine: the files whose extensions match the ``--extension`` option (``py`` by default) and the files whose names are passed From 9ee9a7265a76d435d886942ee431a216564d13f0 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 20 Nov 2012 16:14:20 -0500 Subject: [PATCH 158/367] [1.4.X] Fixed #19317 - Added an image for warning blocks in the docs Thanks tome for the suggestion and patch. Backport of 3587991ba8 from master --- docs/_theme/djangodocs/static/djangodocs.css | 5 +++-- .../_theme/djangodocs/static/docicons-warning.png | Bin 0 -> 782 bytes 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/_theme/djangodocs/static/docicons-warning.png diff --git a/docs/_theme/djangodocs/static/djangodocs.css b/docs/_theme/djangodocs/static/djangodocs.css index 4adb8387cccc..4efb7e04f3b0 100644 --- a/docs/_theme/djangodocs/static/djangodocs.css +++ b/docs/_theme/djangodocs/static/djangodocs.css @@ -1,7 +1,7 @@ /*** setup ***/ html { background:#092e20;} body { font:12px/1.5 Verdana,sans-serif; background:#092e20; color: white;} -#custom-doc { width:76.54em;*width:74.69em;min-width:995px; max-width:100em; margin:auto; text-align:left; padding-top:16px; margin-top:0;} +#custom-doc { width:76.54em;*width:74.69em;min-width:995px; max-width:100em; margin:auto; text-align:left; padding-top:16px; margin-top:0;} #hd { padding: 4px 0 12px 0; } #bd { background:#234F32; } #ft { color:#487858; font-size:90%; padding-bottom: 2em; } @@ -54,7 +54,7 @@ hr { color:#ccc; background-color:#ccc; height:1px; border:0; } p, ul, dl { margin-top:.6em; margin-bottom:1em; padding-bottom: 0.1em;} #yui-main div.yui-b img { max-width: 50em; margin-left: auto; margin-right: auto; display: block; } caption { font-size:1em; font-weight:bold; margin-top:0.5em; margin-bottom:0.5em; margin-left: 2px; text-align: center; } -blockquote { padding: 0 1em; margin: 1em 0; font:125%/1.2em "Trebuchet MS", sans-serif; color:#234f32; border-left:2px solid #94da3a; } +blockquote { padding: 0 1em; margin: 1em 0; font:125%/1.2em "Trebuchet MS", sans-serif; color:#234f32; border-left:2px solid #94da3a; } strong { font-weight: bold; } em { font-style: italic; } ins { font-weight: bold; text-decoration: none; } @@ -111,6 +111,7 @@ dt .literal, table .literal { background:none; } .note, .admonition { padding-left:65px; background:url(docicons-note.png) .8em .8em no-repeat;} div.admonition-philosophy { padding-left:65px; background:url(docicons-philosophy.png) .8em .8em no-repeat;} div.admonition-behind-the-scenes { padding-left:65px; background:url(docicons-behindscenes.png) .8em .8em no-repeat;} +.admonition.warning { background:url(docicons-warning.png) .8em .8em no-repeat; border:1px solid #ffc83c;} /*** versoinadded/changes ***/ div.versionadded, div.versionchanged { } diff --git a/docs/_theme/djangodocs/static/docicons-warning.png b/docs/_theme/djangodocs/static/docicons-warning.png new file mode 100644 index 0000000000000000000000000000000000000000..031b3e782a8c2a481fd56b7224219928e9efab2f GIT binary patch literal 782 zcmV+p1M&QcP);L9&<{(0?BTyN^z_ZbPbU(Ed8oV>t zkB+yoNB{VOt}|?h+M{-r!5_TnL`N1ns_;P9y@`rwpZu`Gmy&UO#lMt5*F6OJL4Pdp zPGZ?@Z~wMu!QLEeX#1a@xql1f*EjrDqDj0)54|=u9D&=d%@7-nO3grbpFc#s%m+(w zc!u`~PB!_p8}j))H2Pazc@JxEA9|h8mN$6EO!*nFYT(5@NV$U-lha>BQ3o#~QT%dv z3R3~1iZb|rQRqS#4Lr_6gO`l|iz{d4M#GB``hO*^fu|)u*aD-z=fwkTpNB6dhB$JS zP~eG^c+cP|yJJ||!+{r*cfAJQXIgu0@W?CRX=A{YRD}jlhTPQaOIpGsci`B5jqn*m z>@9VKM{XK6N1FEhkx|{{>)7+jW1}ihXbFF!dCyk`Aq~g8d8mD~$> zK)GCQ7%FYshFCPBK(}E~%#^}ksh9=SDwV;z(kcV0#OmPXg$7oAUJblgecoqfgdg5- zWu$fpxts&NU2R}l7UXg{sio6t7EP=_Kc4rS7o%$fn^X$|I30bwLK7_DUJ@rA?F`fGO3m2ABSjqQ Date: Sat, 24 Nov 2012 00:28:20 +0200 Subject: [PATCH 159/367] [1.4.x] Fixed SQLite's collapsing of same-valued instances in bulk_create SQLite used INSERT INTO tbl SELECT %s UNION SELECT %s, the problem was that there should have been UNION ALL instead of UNION. Refs #19351 Backpatch of a27582484cf814554907d2d1ad077852de36963f --- django/db/backends/sqlite3/base.py | 2 +- tests/regressiontests/bulk_create/tests.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 2146a7fa8a80..f31d2ab3ebc2 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -215,7 +215,7 @@ def bulk_insert_sql(self, fields, num_values): res.append("SELECT %s" % ", ".join( "%%s AS %s" % self.quote_name(f.column) for f in fields )) - res.extend(["UNION SELECT %s" % ", ".join(["%s"] * len(fields))] * (num_values - 1)) + res.extend(["UNION ALL SELECT %s" % ", ".join(["%s"] * len(fields))] * (num_values - 1)) return " ".join(res) class DatabaseWrapper(BaseDatabaseWrapper): diff --git a/tests/regressiontests/bulk_create/tests.py b/tests/regressiontests/bulk_create/tests.py index b4c3e7f17ffe..f75d983a0672 100644 --- a/tests/regressiontests/bulk_create/tests.py +++ b/tests/regressiontests/bulk_create/tests.py @@ -59,6 +59,14 @@ def test_non_auto_increment_pk(self): "CA", "IL", "ME", "NY", ], attrgetter("two_letter_code")) + def test_batch_same_vals(self): + # Sqlite had a problem where all the same-valued models were + # collapsed to one insert. + Restaurant.objects.bulk_create([ + Restaurant(name='foo') for i in range(0, 2) + ]) + self.assertEqual(Restaurant.objects.count(), 2) + def test_large_batch(self): with override_settings(DEBUG=True): connection.queries = [] From 046300c43b44c3238e980f01c177170ed4efde34 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Sat, 24 Nov 2012 09:36:42 +0100 Subject: [PATCH 160/367] [1.4.x] Restored Python 2.5 compatibility in m2m_through_regress tests. Refs #18823. --- tests/regressiontests/m2m_through_regress/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/regressiontests/m2m_through_regress/tests.py b/tests/regressiontests/m2m_through_regress/tests.py index 6da089fcb672..d215f83af26c 100644 --- a/tests/regressiontests/m2m_through_regress/tests.py +++ b/tests/regressiontests/m2m_through_regress/tests.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import +from __future__ import absolute_import, with_statement try: from cStringIO import StringIO From 3e4058be9f3f7bc58a356278a70d44c07f1c967c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Sat, 24 Nov 2012 15:52:00 +0200 Subject: [PATCH 161/367] [1.4.x] Fixed ordering-related failure in m2m_through_regress tests Backpatch of dc569c880143db07e01b3293d698ad8fe4a0136f --- tests/regressiontests/m2m_through_regress/models.py | 3 +++ tests/regressiontests/m2m_through_regress/tests.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/regressiontests/m2m_through_regress/models.py b/tests/regressiontests/m2m_through_regress/models.py index 9cd1475fd5bb..8f812c94592c 100644 --- a/tests/regressiontests/m2m_through_regress/models.py +++ b/tests/regressiontests/m2m_through_regress/models.py @@ -66,6 +66,9 @@ class Driver(models.Model): def __unicode__(self): return "%s" % self.name + class Meta: + ordering = ('name',) + class CarDriver(models.Model): car = models.ForeignKey('Car', to_field='make') driver = models.ForeignKey('Driver', to_field='name') diff --git a/tests/regressiontests/m2m_through_regress/tests.py b/tests/regressiontests/m2m_through_regress/tests.py index d215f83af26c..a3b57dfd6a62 100644 --- a/tests/regressiontests/m2m_through_regress/tests.py +++ b/tests/regressiontests/m2m_through_regress/tests.py @@ -172,7 +172,7 @@ def test_add(self): self.car.drivers._add_items('car', 'driver', self.unused_driver) self.assertQuerysetEqual( self.car.drivers.all(), - ["", ""] + ["", ""] ) def test_add_null(self): From c72172244ef81f793d1eca9a54f58a11f27b0917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastia=CC=81n=20Magri=CC=81?= Date: Sun, 18 Nov 2012 20:34:39 -0430 Subject: [PATCH 162/367] [1.4.x] Fixed #19318 -- Ensured that the admin's SimpleListFilter options can be displayed as selected even if the lookup's first element is not a string. Backport of 88e17156393b --- django/contrib/admin/filters.py | 4 +- tests/regressiontests/admin_filters/tests.py | 58 +++++++++++++++++--- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/django/contrib/admin/filters.py b/django/contrib/admin/filters.py index 6a97fb5c0b72..fda580e85c92 100644 --- a/django/contrib/admin/filters.py +++ b/django/contrib/admin/filters.py @@ -9,7 +9,7 @@ from django.db import models from django.core.exceptions import ImproperlyConfigured, ValidationError -from django.utils.encoding import smart_unicode +from django.utils.encoding import smart_unicode, force_unicode from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from django.contrib.admin.util import (get_model_from_relation, @@ -102,7 +102,7 @@ def choices(self, cl): } for lookup, title in self.lookup_choices: yield { - 'selected': self.value() == lookup, + 'selected': self.value() == force_unicode(lookup), 'query_string': cl.get_query_string({ self.parameter_name: lookup, }, []), diff --git a/tests/regressiontests/admin_filters/tests.py b/tests/regressiontests/admin_filters/tests.py index e2a12c966339..57802551549e 100644 --- a/tests/regressiontests/admin_filters/tests.py +++ b/tests/regressiontests/admin_filters/tests.py @@ -77,6 +77,21 @@ class DecadeListFilterParameterEndsWith__Isnull(DecadeListFilter): parameter_name = 'decade__isnull' # Ends with '__isnull" +class DepartmentListFilterLookupWithNonStringValue(SimpleListFilter): + title = 'department' + parameter_name = 'department' + + def lookups(self, request, model_admin): + return set([ + (employee.department.id, # Intentionally not a string (Refs #19318) + employee.department.code) + for employee in model_admin.queryset(request).all() + ]) + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(department__id=self.value()) + class CustomUserAdmin(UserAdmin): list_filter = ('books_authored', 'books_contributed') @@ -116,6 +131,8 @@ class EmployeeAdmin(ModelAdmin): list_display = ['name', 'department'] list_filter = ['department'] +class DepartmentFilterEmployeeAdmin(EmployeeAdmin): + list_filter = [DepartmentListFilterLookupWithNonStringValue, ] class ListFiltersTests(TestCase): @@ -139,6 +156,15 @@ def setUp(self): self.gipsy_book.contributors = [self.bob, self.lisa] self.gipsy_book.save() + # Departments + self.dev = Department.objects.create(code='DEV', description='Development') + self.design = Department.objects.create(code='DSN', description='Design') + + # Employees + self.john = Employee.objects.create(name='John Blue', department=self.dev) + self.jack = Employee.objects.create(name='Jack Red', department=self.design) + + def get_changelist(self, request, model, modeladmin): return ChangeList(request, model, modeladmin.list_display, modeladmin.list_display_links, modeladmin.list_filter, modeladmin.date_hierarchy, modeladmin.search_fields, @@ -637,6 +663,29 @@ def test_parameter_ends_with__in__or__isnull(self): self.assertEqual(choices[2]['selected'], True) self.assertEqual(choices[2]['query_string'], '?decade__isnull=the+90s') + def test_lookup_with_non_string_value(self): + """ + Ensure choices are set the selected class when using + non-string values for lookups in SimpleListFilters + Refs #19318 + """ + + modeladmin = DepartmentFilterEmployeeAdmin(Employee, site) + request = self.request_factory.get('/', {'department': '1'}) + changelist = self.get_changelist(request, Employee, modeladmin) + + queryset = changelist.get_query_set(request) + + self.assertEqual(list(queryset), [self.john]) + + filterspec = changelist.get_filters(request)[0][-1] + self.assertEqual(force_unicode(filterspec.title), u'department') + choices = list(filterspec.choices(changelist)) + + self.assertEqual(choices[2]['display'], u'DEV') + self.assertEqual(choices[2]['selected'], True) + self.assertEqual(choices[2]['query_string'], '?department=1') + def test_fk_with_to_field(self): """ Ensure that a filter on a FK respects the FK's to_field attribute. @@ -644,17 +693,12 @@ def test_fk_with_to_field(self): """ modeladmin = EmployeeAdmin(Employee, site) - dev = Department.objects.create(code='DEV', description='Development') - design = Department.objects.create(code='DSN', description='Design') - john = Employee.objects.create(name='John Blue', department=dev) - jack = Employee.objects.create(name='Jack Red', department=design) - request = self.request_factory.get('/', {}) changelist = self.get_changelist(request, Employee, modeladmin) # Make sure the correct queryset is returned queryset = changelist.get_query_set(request) - self.assertEqual(list(queryset), [jack, john]) + self.assertEqual(list(queryset), [self.jack, self.john]) filterspec = changelist.get_filters(request)[0][-1] self.assertEqual(force_unicode(filterspec.title), u'department') @@ -679,7 +723,7 @@ def test_fk_with_to_field(self): # Make sure the correct queryset is returned queryset = changelist.get_query_set(request) - self.assertEqual(list(queryset), [john]) + self.assertEqual(list(queryset), [self.john]) filterspec = changelist.get_filters(request)[0][-1] self.assertEqual(force_unicode(filterspec.title), u'department') From 8c9a8fd5c4e5ed157d2d5fa09f3d6d05d2290bbf Mon Sep 17 00:00:00 2001 From: Julien Phalip Date: Tue, 4 Dec 2012 10:36:56 -0800 Subject: [PATCH 163/367] [1.4.x] Fixed the admin_filters tests for Postgres. Backport of c196e01100b2 --- tests/regressiontests/admin_filters/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/regressiontests/admin_filters/tests.py b/tests/regressiontests/admin_filters/tests.py index 57802551549e..c75ba6c23d57 100644 --- a/tests/regressiontests/admin_filters/tests.py +++ b/tests/regressiontests/admin_filters/tests.py @@ -671,7 +671,7 @@ def test_lookup_with_non_string_value(self): """ modeladmin = DepartmentFilterEmployeeAdmin(Employee, site) - request = self.request_factory.get('/', {'department': '1'}) + request = self.request_factory.get('/', {'department': self.john.pk}) changelist = self.get_changelist(request, Employee, modeladmin) queryset = changelist.get_query_set(request) @@ -684,7 +684,7 @@ def test_lookup_with_non_string_value(self): self.assertEqual(choices[2]['display'], u'DEV') self.assertEqual(choices[2]['selected'], True) - self.assertEqual(choices[2]['query_string'], '?department=1') + self.assertEqual(choices[2]['query_string'], '?department=%s' % self.john.pk) def test_fk_with_to_field(self): """ From b2ae0a63aeec741f1e51bac9a95a27fd635f9652 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Sat, 17 Nov 2012 22:00:53 +0100 Subject: [PATCH 164/367] [1.4.X] Fixed #18856 -- Ensured that redirects can't be poisoned by malicious users. --- django/contrib/auth/views.py | 51 +++++------ django/contrib/comments/views/comments.py | 11 +-- django/contrib/comments/views/moderation.py | 7 +- django/contrib/comments/views/utils.py | 10 ++- django/utils/http.py | 12 +++ django/views/i18n.py | 12 +-- .../comment_tests/tests/comment_view_tests.py | 7 ++ .../tests/moderation_view_tests.py | 89 ++++++++++++++++++- tests/regressiontests/views/tests/i18n.py | 17 +++- 9 files changed, 162 insertions(+), 54 deletions(-) diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index ac880209082f..62599b87522a 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -4,7 +4,7 @@ from django.core.urlresolvers import reverse from django.http import HttpResponseRedirect, QueryDict from django.template.response import TemplateResponse -from django.utils.http import base36_to_int +from django.utils.http import base36_to_int, is_safe_url from django.utils.translation import ugettext as _ from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.cache import never_cache @@ -34,18 +34,11 @@ def login(request, template_name='registration/login.html', if request.method == "POST": form = authentication_form(data=request.POST) if form.is_valid(): - netloc = urlparse.urlparse(redirect_to)[1] - - # Use default setting if redirect_to is empty - if not redirect_to: - redirect_to = settings.LOGIN_REDIRECT_URL - - # Heavier security check -- don't allow redirection to a different - # host. - elif netloc and netloc != request.get_host(): + # Ensure the user-originating redirection url is safe. + if not is_safe_url(url=redirect_to, host=request.get_host()): redirect_to = settings.LOGIN_REDIRECT_URL - # Okay, security checks complete. Log the user in. + # Okay, security check complete. Log the user in. auth_login(request, form.get_user()) if request.session.test_cookie_worked(): @@ -78,27 +71,27 @@ def logout(request, next_page=None, Logs out the user and displays 'You are logged out' message. """ auth_logout(request) - redirect_to = request.REQUEST.get(redirect_field_name, '') - if redirect_to: - netloc = urlparse.urlparse(redirect_to)[1] + + if redirect_field_name in request.REQUEST: + next_page = request.REQUEST[redirect_field_name] # Security check -- don't allow redirection to a different host. - if not (netloc and netloc != request.get_host()): - return HttpResponseRedirect(redirect_to) + if not is_safe_url(url=next_page, host=request.get_host()): + next_page = request.path - if next_page is None: - current_site = get_current_site(request) - context = { - 'site': current_site, - 'site_name': current_site.name, - 'title': _('Logged out') - } - if extra_context is not None: - context.update(extra_context) - return TemplateResponse(request, template_name, context, - current_app=current_app) - else: + if next_page: # Redirect to this page until the session has been cleared. - return HttpResponseRedirect(next_page or request.path) + return HttpResponseRedirect(next_page) + + current_site = get_current_site(request) + context = { + 'site': current_site, + 'site_name': current_site.name, + 'title': _('Logged out') + } + if extra_context is not None: + context.update(extra_context) + return TemplateResponse(request, template_name, context, + current_app=current_app) def logout_then_login(request, login_url=None, current_app=None, extra_context=None): """ diff --git a/django/contrib/comments/views/comments.py b/django/contrib/comments/views/comments.py index 57720163c3d5..20c172f0d22b 100644 --- a/django/contrib/comments/views/comments.py +++ b/django/contrib/comments/views/comments.py @@ -44,9 +44,6 @@ def post_comment(request, next=None, using=None): if not data.get('email', ''): data["email"] = request.user.email - # Check to see if the POST data overrides the view's next argument. - next = data.get("next", next) - # Look up the object we're trying to comment about ctype = data.get("content_type") object_pk = data.get("object_pk") @@ -98,9 +95,9 @@ def post_comment(request, next=None, using=None): ] return render_to_response( template_list, { - "comment" : form.data.get("comment", ""), - "form" : form, - "next": next, + "comment": form.data.get("comment", ""), + "form": form, + "next": data.get("next", next), }, RequestContext(request, {}) ) @@ -131,7 +128,7 @@ def post_comment(request, next=None, using=None): request = request ) - return next_redirect(data, next, comment_done, c=comment._get_pk_val()) + return next_redirect(request, next, comment_done, c=comment._get_pk_val()) comment_done = confirmation_view( template = "comments/posted.html", diff --git a/django/contrib/comments/views/moderation.py b/django/contrib/comments/views/moderation.py index fb9e91ef9799..1efa10fbd7bf 100644 --- a/django/contrib/comments/views/moderation.py +++ b/django/contrib/comments/views/moderation.py @@ -10,7 +10,6 @@ from django.views.decorators.csrf import csrf_protect - @csrf_protect @login_required def flag(request, comment_id, next=None): @@ -27,7 +26,7 @@ def flag(request, comment_id, next=None): # Flag on POST if request.method == 'POST': perform_flag(request, comment) - return next_redirect(request.POST.copy(), next, flag_done, c=comment.pk) + return next_redirect(request, next, flag_done, c=comment.pk) # Render a form on GET else: @@ -54,7 +53,7 @@ def delete(request, comment_id, next=None): if request.method == 'POST': # Flag the comment as deleted instead of actually deleting it. perform_delete(request, comment) - return next_redirect(request.POST.copy(), next, delete_done, c=comment.pk) + return next_redirect(request, next, delete_done, c=comment.pk) # Render a form on GET else: @@ -81,7 +80,7 @@ def approve(request, comment_id, next=None): if request.method == 'POST': # Flag the comment as approved. perform_approve(request, comment) - return next_redirect(request.POST.copy(), next, approve_done, c=comment.pk) + return next_redirect(request, next, approve_done, c=comment.pk) # Render a form on GET else: diff --git a/django/contrib/comments/views/utils.py b/django/contrib/comments/views/utils.py index cc985e52d203..94ee2470d526 100644 --- a/django/contrib/comments/views/utils.py +++ b/django/contrib/comments/views/utils.py @@ -4,14 +4,15 @@ import urllib import textwrap -from django.http import HttpResponseRedirect from django.core import urlresolvers +from django.http import HttpResponseRedirect from django.shortcuts import render_to_response from django.template import RequestContext from django.core.exceptions import ObjectDoesNotExist from django.contrib import comments +from django.utils.http import is_safe_url -def next_redirect(data, default, default_view, **get_kwargs): +def next_redirect(request, default, default_view, **get_kwargs): """ Handle the "where should I go next?" part of comment views. @@ -21,9 +22,10 @@ def next_redirect(data, default, default_view, **get_kwargs): Returns an ``HttpResponseRedirect``. """ - next = data.get("next", default) - if next is None: + next = request.POST.get('next', default) + if not is_safe_url(url=next, host=request.get_host()): next = urlresolvers.reverse(default_view) + if get_kwargs: if '#' in next: tmp = next.rsplit('#', 1) diff --git a/django/utils/http.py b/django/utils/http.py index d343a375c02c..d2e4eb5adbf0 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -224,3 +224,15 @@ def same_origin(url1, url2): """ p1, p2 = urlparse.urlparse(url1), urlparse.urlparse(url2) return p1[0:2] == p2[0:2] + +def is_safe_url(url, host=None): + """ + Return ``True`` if the url is a safe redirection (i.e. it doesn't point to + a different host). + + Always returns ``False`` on an empty url. + """ + if not url: + return False + netloc = urlparse.urlparse(url)[1] + return not netloc or netloc == host diff --git a/django/views/i18n.py b/django/views/i18n.py index 140dc543e3d5..e288d227f749 100644 --- a/django/views/i18n.py +++ b/django/views/i18n.py @@ -8,6 +8,8 @@ from django.utils.text import javascript_quote from django.utils.encoding import smart_unicode from django.utils.formats import get_format_modules, get_format +from django.utils.http import is_safe_url + def set_language(request): """ @@ -20,11 +22,11 @@ def set_language(request): redirect to the page in the request (the 'next' parameter) without changing any state. """ - next = request.REQUEST.get('next', None) - if not next: - next = request.META.get('HTTP_REFERER', None) - if not next: - next = '/' + next = request.REQUEST.get('next') + if not is_safe_url(url=next, host=request.get_host()): + next = request.META.get('HTTP_REFERER') + if not is_safe_url(url=next, host=request.get_host()): + next = '/' response = http.HttpResponseRedirect(next) if request.method == 'POST': lang_code = request.POST.get('language', None) diff --git a/tests/regressiontests/comment_tests/tests/comment_view_tests.py b/tests/regressiontests/comment_tests/tests/comment_view_tests.py index 5edc58fe2d69..5a8269368dcb 100644 --- a/tests/regressiontests/comment_tests/tests/comment_view_tests.py +++ b/tests/regressiontests/comment_tests/tests/comment_view_tests.py @@ -222,6 +222,13 @@ def testCommentNext(self): match = re.search(r"^http://testserver/somewhere/else/\?c=\d+$", location) self.assertTrue(match != None, "Unexpected redirect location: %s" % location) + data["next"] = "http://badserver/somewhere/else/" + data["comment"] = "This is another comment with an unsafe next url" + response = self.client.post("/post/", data) + location = response["Location"] + match = post_redirect_re.match(location) + self.assertTrue(match != None, "Unsafe redirection to: %s" % location) + def testCommentDoneView(self): a = Article.objects.get(pk=1) data = self.getValidData(a) diff --git a/tests/regressiontests/comment_tests/tests/moderation_view_tests.py b/tests/regressiontests/comment_tests/tests/moderation_view_tests.py index e9d2fb157850..54f3f3a86b89 100644 --- a/tests/regressiontests/comment_tests/tests/moderation_view_tests.py +++ b/tests/regressiontests/comment_tests/tests/moderation_view_tests.py @@ -29,6 +29,30 @@ def testFlagPost(self): self.assertEqual(c.flags.filter(flag=CommentFlag.SUGGEST_REMOVAL).count(), 1) return c + def testFlagPostNext(self): + """ + POST the flag view, explicitly providing a next url. + """ + comments = self.createSomeComments() + pk = comments[0].pk + self.client.login(username="normaluser", password="normaluser") + response = self.client.post("/flag/%d/" % pk, {'next': "/go/here/"}) + self.assertEqual(response["Location"], + "http://testserver/go/here/?c=1") + + def testFlagPostUnsafeNext(self): + """ + POSTing to the flag view with an unsafe next url will ignore the + provided url when redirecting. + """ + comments = self.createSomeComments() + pk = comments[0].pk + self.client.login(username="normaluser", password="normaluser") + response = self.client.post("/flag/%d/" % pk, + {'next': "http://elsewhere/bad"}) + self.assertEqual(response["Location"], + "http://testserver/flagged/?c=%d" % pk) + def testFlagPostTwice(self): """Users don't get to flag comments more than once.""" c = self.testFlagPost() @@ -48,7 +72,7 @@ def testFlagAnon(self): def testFlaggedView(self): comments = self.createSomeComments() pk = comments[0].pk - response = self.client.get("/flagged/", data={"c":pk}) + response = self.client.get("/flagged/", data={"c": pk}) self.assertTemplateUsed(response, "comments/flagged.html") def testFlagSignals(self): @@ -100,6 +124,33 @@ def testDeletePost(self): self.assertTrue(c.is_removed) self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_DELETION, user__username="normaluser").count(), 1) + def testDeletePostNext(self): + """ + POSTing the delete view will redirect to an explicitly provided a next + url. + """ + comments = self.createSomeComments() + pk = comments[0].pk + makeModerator("normaluser") + self.client.login(username="normaluser", password="normaluser") + response = self.client.post("/delete/%d/" % pk, {'next': "/go/here/"}) + self.assertEqual(response["Location"], + "http://testserver/go/here/?c=1") + + def testDeletePostUnsafeNext(self): + """ + POSTing to the delete view with an unsafe next url will ignore the + provided url when redirecting. + """ + comments = self.createSomeComments() + pk = comments[0].pk + makeModerator("normaluser") + self.client.login(username="normaluser", password="normaluser") + response = self.client.post("/delete/%d/" % pk, + {'next': "http://elsewhere/bad"}) + self.assertEqual(response["Location"], + "http://testserver/deleted/?c=%d" % pk) + def testDeleteSignals(self): def receive(sender, **kwargs): received_signals.append(kwargs.get('signal')) @@ -115,13 +166,13 @@ def receive(sender, **kwargs): def testDeletedView(self): comments = self.createSomeComments() pk = comments[0].pk - response = self.client.get("/deleted/", data={"c":pk}) + response = self.client.get("/deleted/", data={"c": pk}) self.assertTemplateUsed(response, "comments/deleted.html") class ApproveViewTests(CommentTestCase): def testApprovePermissions(self): - """The delete view should only be accessible to 'moderators'""" + """The approve view should only be accessible to 'moderators'""" comments = self.createSomeComments() pk = comments[0].pk self.client.login(username="normaluser", password="normaluser") @@ -133,7 +184,7 @@ def testApprovePermissions(self): self.assertEqual(response.status_code, 200) def testApprovePost(self): - """POSTing the delete view should mark the comment as removed""" + """POSTing the approve view should mark the comment as removed""" c1, c2, c3, c4 = self.createSomeComments() c1.is_public = False; c1.save() @@ -145,6 +196,36 @@ def testApprovePost(self): self.assertTrue(c.is_public) self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_APPROVAL, user__username="normaluser").count(), 1) + def testApprovePostNext(self): + """ + POSTing the approve view will redirect to an explicitly provided a next + url. + """ + c1, c2, c3, c4 = self.createSomeComments() + c1.is_public = False; c1.save() + + makeModerator("normaluser") + self.client.login(username="normaluser", password="normaluser") + response = self.client.post("/approve/%d/" % c1.pk, + {'next': "/go/here/"}) + self.assertEqual(response["Location"], + "http://testserver/go/here/?c=1") + + def testApprovePostUnsafeNext(self): + """ + POSTing to the approve view with an unsafe next url will ignore the + provided url when redirecting. + """ + c1, c2, c3, c4 = self.createSomeComments() + c1.is_public = False; c1.save() + + makeModerator("normaluser") + self.client.login(username="normaluser", password="normaluser") + response = self.client.post("/approve/%d/" % c1.pk, + {'next': "http://elsewhere/bad"}) + self.assertEqual(response["Location"], + "http://testserver/approved/?c=%d" % c1.pk) + def testApproveSignals(self): def receive(sender, **kwargs): received_signals.append(kwargs.get('signal')) diff --git a/tests/regressiontests/views/tests/i18n.py b/tests/regressiontests/views/tests/i18n.py index 877cf964ddb3..68c3e81c4d91 100644 --- a/tests/regressiontests/views/tests/i18n.py +++ b/tests/regressiontests/views/tests/i18n.py @@ -16,13 +16,28 @@ class I18NTests(TestCase): """ Tests django views in django/views/i18n.py """ def test_setlang(self): - """The set_language view can be used to change the session language""" + """ + The set_language view can be used to change the session language. + + The user is redirected to the 'next' argument if provided. + """ for lang_code, lang_name in settings.LANGUAGES: post_data = dict(language=lang_code, next='/views/') response = self.client.post('/views/i18n/setlang/', data=post_data) self.assertRedirects(response, 'http://testserver/views/') self.assertEqual(self.client.session['django_language'], lang_code) + def test_setlang_unsafe_next(self): + """ + The set_language view only redirects to the 'next' argument if it is + "safe". + """ + lang_code, lang_name = settings.LANGUAGES[0] + post_data = dict(language=lang_code, next='//unsafe/redirection/') + response = self.client.post('/views/i18n/setlang/', data=post_data) + self.assertEqual(response['Location'], 'http://testserver/') + self.assertEqual(self.client.session['django_language'], lang_code) + def test_jsi18n(self): """The javascript_catalog can be deployed with language settings""" saved_lang = get_language() From 319627c184e71ae267d6b7f000e293168c7b6e09 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Tue, 27 Nov 2012 22:26:29 +0100 Subject: [PATCH 165/367] [1.4.X] Fixed a security issue in get_host. Full disclosure and new release forthcoming. --- django/http/__init__.py | 4 +++- docs/topics/security.txt | 27 +++++++++++++++++++++++++ tests/regressiontests/requests/tests.py | 11 +++++++--- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/django/http/__init__.py b/django/http/__init__.py index 98ec9966c490..da993eb8d3ba 100644 --- a/django/http/__init__.py +++ b/django/http/__init__.py @@ -126,6 +126,8 @@ def __init__(self, *args, **kwargs): RESERVED_CHARS="!*'();:@&=+$,/?%#[]" absolute_http_url_re = re.compile(r"^https?://", re.I) +host_validation_re = re.compile(r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9:]+\])(:\d+)?$") + class Http404(Exception): pass @@ -214,7 +216,7 @@ def get_host(self): host = '%s:%s' % (host, server_port) # Disallow potentially poisoned hostnames. - if set(';/?@&=+$,').intersection(host): + if not host_validation_re.match(host.lower()): raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host) return host diff --git a/docs/topics/security.txt b/docs/topics/security.txt index 151853d4ac5a..0b5112803c37 100644 --- a/docs/topics/security.txt +++ b/docs/topics/security.txt @@ -167,6 +167,33 @@ recommend you ensure your Web server is configured such that: Additionally, as of 1.3.1, Django requires you to explicitly enable support for the ``X-Forwarded-Host`` header if your configuration requires it. +Configuration for Apache +------------------------ + +The easiest way to get the described behavior in Apache is as follows. Create +a `virtual host`_ using the ServerName_ and ServerAlias_ directives to restrict +the domains Apache reacts to. Please keep in mind that while the directives do +support ports the match is only performed against the hostname. This means that +the ``Host`` header could still contain a port pointing to another webserver on +the same machine. The next step is to make sure that your newly created virtual +host is not also the default virtual host. Apache uses the first virtual host +found in the configuration file as default virtual host. As such you have to +ensure that you have another virtual host which will act as catch-all virtual +host. Just add one if you do not have one already, there is nothing special +about it aside from ensuring it is the first virtual host in the configuration +file. Debian/Ubuntu users usually don't have to take any action, since Apache +ships with a default virtual host in ``sites-available`` which is linked into +``sites-enabled`` as ``000-default`` and included from ``apache2.conf``. Just +make sure not to name your site ``000-abc``, since files are included in +alphabetical order. + +.. _virtual host: http://httpd.apache.org/docs/2.2/vhosts/ +.. _ServerName: http://httpd.apache.org/docs/2.2/mod/core.html#servername +.. _ServerAlias: http://httpd.apache.org/docs/2.2/mod/core.html#serveralias + + + + Additional security topics ========================== diff --git a/tests/regressiontests/requests/tests.py b/tests/regressiontests/requests/tests.py index cf8fed0253cf..caa25aea218e 100644 --- a/tests/regressiontests/requests/tests.py +++ b/tests/regressiontests/requests/tests.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from __future__ import with_statement import time @@ -154,13 +155,15 @@ def test_http_get_host(self): '12.34.56.78:443', '[2001:19f0:feee::dead:beef:cafe]', '[2001:19f0:feee::dead:beef:cafe]:8080', + 'xn--4ca9at.com', # Punnycode for öäü.com ] poisoned_hosts = [ 'example.com@evil.tld', 'example.com:dr.frankenstein@evil.tld', - 'example.com:someone@somestie.com:80', - 'example.com:80/badpath' + 'example.com:dr.frankenstein@evil.tld:80', + 'example.com:80/badpath', + 'example.com: recovermypassword.com', ] for host in legit_hosts: @@ -230,13 +233,15 @@ def test_http_get_host_with_x_forwarded_host(self): '12.34.56.78:443', '[2001:19f0:feee::dead:beef:cafe]', '[2001:19f0:feee::dead:beef:cafe]:8080', + 'xn--4ca9at.com', # Punnycode for öäü.com ] poisoned_hosts = [ 'example.com@evil.tld', 'example.com:dr.frankenstein@evil.tld', 'example.com:dr.frankenstein@evil.tld:80', - 'example.com:80/badpath' + 'example.com:80/badpath', + 'example.com: recovermypassword.com', ] for host in legit_hosts: From 1f0af3c529885beca39e0d4981fb4794ef3102c2 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Mon, 10 Dec 2012 15:45:04 -0600 Subject: [PATCH 166/367] [1.4.x] Bump version numbers for security release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index f4814c21435b..2c2cfb05b7df 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 2, 'final', 0) +VERSION = (1, 4, 3, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 10d34ad7fc89..45e2b502d47b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.2' +version = '1.4.3' # The full version, including alpha/beta/rc tags. -release = '1.4.2' +release = '1.4.3' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index 8c5c198f79d2..e9ffb0c53738 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.2.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.3.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From f2530dcb17bf0601cb48e452d1a67d35e5e5d518 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Mon, 10 Dec 2012 23:34:51 +0100 Subject: [PATCH 167/367] [1.4.X] Fixed a test failure in the comment tests. Backport of 1eb0da1c5ba3096f218d1df13d02a2b8e1ac7a36 from master. --- .../comment_tests/tests/moderation_view_tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/regressiontests/comment_tests/tests/moderation_view_tests.py b/tests/regressiontests/comment_tests/tests/moderation_view_tests.py index 54f3f3a86b89..f6fb4e2ab60f 100644 --- a/tests/regressiontests/comment_tests/tests/moderation_view_tests.py +++ b/tests/regressiontests/comment_tests/tests/moderation_view_tests.py @@ -38,7 +38,7 @@ def testFlagPostNext(self): self.client.login(username="normaluser", password="normaluser") response = self.client.post("/flag/%d/" % pk, {'next': "/go/here/"}) self.assertEqual(response["Location"], - "http://testserver/go/here/?c=1") + "http://testserver/go/here/?c=%d" % pk) def testFlagPostUnsafeNext(self): """ @@ -135,7 +135,7 @@ def testDeletePostNext(self): self.client.login(username="normaluser", password="normaluser") response = self.client.post("/delete/%d/" % pk, {'next': "/go/here/"}) self.assertEqual(response["Location"], - "http://testserver/go/here/?c=1") + "http://testserver/go/here/?c=%d" % pk) def testDeletePostUnsafeNext(self): """ @@ -209,7 +209,7 @@ def testApprovePostNext(self): response = self.client.post("/approve/%d/" % c1.pk, {'next': "/go/here/"}) self.assertEqual(response["Location"], - "http://testserver/go/here/?c=1") + "http://testserver/go/here/?c=%d" % c1.pk) def testApprovePostUnsafeNext(self): """ From 8ab2aceb6591e5e7c2f50e85a90ec85b42c5c16f Mon Sep 17 00:00:00 2001 From: Alex Gaynor Date: Wed, 11 Apr 2012 02:03:59 +0000 Subject: [PATCH 168/367] [1.4.X] Fixed #18099 -- corrected a typo in the initial data docs. Thanks to Bradley Ayers for the patch. Backport of f5a9e5e9 from master --- docs/howto/initial-data.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/initial-data.txt b/docs/howto/initial-data.txt index 36306d476e04..295b74cf774d 100644 --- a/docs/howto/initial-data.txt +++ b/docs/howto/initial-data.txt @@ -163,4 +163,4 @@ Backend-specific SQL data is executed before non-backend-specific SQL data. For example, if your app contains the files ``sql/person.sql`` and ``sql/person.sqlite3.sql`` and you're installing the app on SQLite, Django will execute the contents of -``sql/person.sqlite.sql`` first, then ``sql/person.sql``. +``sql/person.sqlite3.sql`` first, then ``sql/person.sql``. From 647410510721989018a8942e4d1b1f8096798e7a Mon Sep 17 00:00:00 2001 From: Ramiro Morales Date: Wed, 19 Dec 2012 15:07:52 -0300 Subject: [PATCH 169/367] [1.4.x] Added PASSWORD_HASHERS to settings reference document. abd0f304b162b3120b1c7321fbfc3090e5f3c92c from master. --- docs/ref/settings.txt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 34664822b5a4..d7b100f0b9ba 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1522,6 +1522,25 @@ format has higher precedence and will be applied instead. See also :setting:`DECIMAL_SEPARATOR`, :setting:`THOUSAND_SEPARATOR` and :setting:`USE_THOUSAND_SEPARATOR`. +.. setting:: PASSWORD_HASHERS + +PASSWORD_HASHERS +---------------- + +.. versionadded:: 1.4 + +See :ref:`auth_password_storage`. + +Default:: + + ('django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.BCryptPasswordHasher', + 'django.contrib.auth.hashers.SHA1PasswordHasher', + 'django.contrib.auth.hashers.MD5PasswordHasher', + 'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', + 'django.contrib.auth.hashers.CryptPasswordHasher',) + .. setting:: PASSWORD_RESET_TIMEOUT_DAYS PASSWORD_RESET_TIMEOUT_DAYS From c4a9e5bd8d4c158e63b2fc77796a961ba0a191f4 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 21 Dec 2012 15:52:06 -0500 Subject: [PATCH 170/367] [1.4.X] Fixed #19506 - Remove 'mysite' prefix in model example. Thanks Mike O'Connor for the report. Backport of 52a2588df6 from master --- docs/topics/db/models.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/db/models.txt b/docs/topics/db/models.txt index a0b7416a4d24..a9968ac0756d 100644 --- a/docs/topics/db/models.txt +++ b/docs/topics/db/models.txt @@ -66,13 +66,13 @@ those models. Do this by editing your settings file and changing the your ``models.py``. For example, if the models for your application live in the module -``mysite.myapp.models`` (the package structure that is created for an +``myapp.models`` (the package structure that is created for an application by the :djadmin:`manage.py startapp ` script), :setting:`INSTALLED_APPS` should read, in part:: INSTALLED_APPS = ( #... - 'mysite.myapp', + 'myapp', #... ) From c26541f5cb0f069ff9562fa34d8408e69f9976e8 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 9 Jan 2013 19:03:34 -0500 Subject: [PATCH 171/367] [1.4.x] Addeded CSS to bold deprecation notices. Thanks Sam Lai for mentioning this on the mailing list. Backport of 227bd3f8db from master --- docs/_theme/djangodocs/static/djangodocs.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_theme/djangodocs/static/djangodocs.css b/docs/_theme/djangodocs/static/djangodocs.css index 4efb7e04f3b0..bab81cd919c6 100644 --- a/docs/_theme/djangodocs/static/djangodocs.css +++ b/docs/_theme/djangodocs/static/djangodocs.css @@ -115,7 +115,7 @@ div.admonition-behind-the-scenes { padding-left:65px; background:url(docicons-be /*** versoinadded/changes ***/ div.versionadded, div.versionchanged { } -div.versionadded span.title, div.versionchanged span.title { font-weight: bold; } +div.versionadded span.title, div.versionchanged span.title, div.deprecated span.title { font-weight: bold; } /*** p-links ***/ a.headerlink { color: #c60f0f; font-size: 0.8em; padding: 0 4px 0 4px; text-decoration: none; visibility: hidden; } From 89ba1b27b4442cbb43555f607ab7d0f189a2af50 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 8 Jan 2013 15:43:35 -0500 Subject: [PATCH 172/367] [1.4.x] Fixed #19555 - Removed '2012' from tutorial 1. Thanks rodrigorosa.lg and others for the report. Backport of 99315f709e from master --- docs/intro/tutorial01.txt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/intro/tutorial01.txt b/docs/intro/tutorial01.txt index d37564055e87..c9b9af35f64c 100644 --- a/docs/intro/tutorial01.txt +++ b/docs/intro/tutorial01.txt @@ -658,8 +658,10 @@ Save these changes and start a new Python interactive shell by running >>> Poll.objects.filter(question__startswith='What') [] - # Get the poll whose year is 2012. - >>> Poll.objects.get(pub_date__year=2012) + # Get the poll that was published this year. + >>> from django.utils import timezone + >>> current_year = timezone.now().year + >>> Poll.objects.get(pub_date__year=current_year) >>> Poll.objects.get(id=2) @@ -709,8 +711,9 @@ Save these changes and start a new Python interactive shell by running # The API automatically follows relationships as far as you need. # Use double underscores to separate relationships. # This works as many levels deep as you want; there's no limit. - # Find all Choices for any poll whose pub_date is in 2012. - >>> Choice.objects.filter(poll__pub_date__year=2012) + # Find all Choices for any poll whose pub_date is in this year + # (reusing the 'current_year' variable we created above). + >>> Choice.objects.filter(poll__pub_date__year=current_year) [, , ] # Let's delete one of the choices. Use delete() for that. From 6bd3896fcb5c626a5ef613895d52c69130156d3a Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sat, 2 Feb 2013 11:57:25 +0100 Subject: [PATCH 173/367] [1.4.x] Fixed #18144 -- Added backwards compatibility with old unsalted MD5 passwords Thanks apreobrazhensky at gmail.com for the report. Backport of 63d6a50dd from master. --- django/contrib/auth/hashers.py | 5 ++++- django/contrib/auth/tests/hashers.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index 58246852a8af..1a93e8945bbf 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -35,7 +35,8 @@ def check_password(password, encoded, setter=None, preferred='default'): password = smart_str(password) encoded = smart_str(encoded) - if len(encoded) == 32 and '$' not in encoded: + if ((len(encoded) == 32 and '$' not in encoded) or + (len(encoded) == 37 and encoded.startswith('md5$$'))): hasher = get_hasher('unsalted_md5') else: algorithm = encoded.split('$', 1)[0] @@ -347,6 +348,8 @@ def encode(self, password, salt): return hashlib.md5(password).hexdigest() def verify(self, password, encoded): + if len(encoded) == 37 and encoded.startswith('md5$$'): + encoded = encoded[5:] encoded_2 = self.encode(password, '') return constant_time_compare(encoded, encoded_2) diff --git a/django/contrib/auth/tests/hashers.py b/django/contrib/auth/tests/hashers.py index 865085a194cc..6203e9a89956 100644 --- a/django/contrib/auth/tests/hashers.py +++ b/django/contrib/auth/tests/hashers.py @@ -59,6 +59,11 @@ def test_unsalted_md5(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) + # Alternate unsalted syntax + alt_encoded = "md5$$%s" % encoded + self.assertTrue(is_password_usable(alt_encoded)) + self.assertTrue(check_password(u'letmein', alt_encoded)) + self.assertFalse(check_password('letmeinz', alt_encoded)) @skipUnless(crypt, "no crypt module to generate password.") def test_crypt(self): From 3610d11ba06f242d409100fd456ecf22d3bfcf7f Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sat, 2 Feb 2013 14:00:38 +0100 Subject: [PATCH 174/367] [1.5.x] Lowered field ordering requirement in ogrinspect test This test was randomly failing depending on the library environment. Backport of a1c470a6f from master. --- django/contrib/gis/tests/inspectapp/tests.py | 36 +++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/django/contrib/gis/tests/inspectapp/tests.py b/django/contrib/gis/tests/inspectapp/tests.py index a3d19784c2af..216aea74c7ff 100644 --- a/django/contrib/gis/tests/inspectapp/tests.py +++ b/django/contrib/gis/tests/inspectapp/tests.py @@ -68,23 +68,27 @@ def test_time_field(self): layer_key=AllOGRFields._meta.db_table, decimal=['f_decimal']) - expected = [ - '# This is an auto-generated Django model module created by ogrinspect.', - 'from django.contrib.gis.db import models', - '', - 'class Measurement(models.Model):', - ' f_decimal = models.DecimalField(max_digits=0, decimal_places=0)', - ' f_int = models.IntegerField()', - ' f_datetime = models.DateTimeField()', - ' f_time = models.TimeField()', - ' f_float = models.FloatField()', - ' f_char = models.CharField(max_length=10)', - ' f_date = models.DateField()', - ' geom = models.PolygonField()', - ' objects = models.GeoManager()', - ] + self.assertTrue(model_def.startswith( + '# This is an auto-generated Django model module created by ogrinspect.\n' + 'from django.contrib.gis.db import models\n' + '\n' + 'class Measurement(models.Model):\n' + )) + + # The ordering of model fields might vary depending on several factors (version of GDAL, etc.) + self.assertIn(' f_decimal = models.DecimalField(max_digits=0, decimal_places=0)', model_def) + self.assertIn(' f_int = models.IntegerField()', model_def) + self.assertIn(' f_datetime = models.DateTimeField()', model_def) + self.assertIn(' f_time = models.TimeField()', model_def) + self.assertIn(' f_float = models.FloatField()', model_def) + self.assertIn(' f_char = models.CharField(max_length=10)', model_def) + self.assertIn(' f_date = models.DateField()', model_def) + + self.assertTrue(model_def.endswith( + ' geom = models.PolygonField()\n' + ' objects = models.GeoManager()' + )) - self.assertEqual(model_def, '\n'.join(expected)) def get_ogr_db_string(): # Construct the DB string that GDAL will use to inspect the database. From ec93ecdd1071e5552fa10afa1160e2081cfc1de0 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sat, 2 Feb 2013 14:21:26 +0100 Subject: [PATCH 175/367] [1.4.x] Fixed #19702 -- Changed a SQL command syntax to be MySQL 4-compatible Thanks matf at op.pl for the report. --- django/db/backends/mysql/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/db/backends/mysql/base.py b/django/db/backends/mysql/base.py index 91dc4d2b37d2..f9c1e2e0b250 100644 --- a/django/db/backends/mysql/base.py +++ b/django/db/backends/mysql/base.py @@ -177,7 +177,7 @@ def _mysql_storage_engine(self): # will tell you the default table type of the created # table. Since all Django's test tables will have the same # table type, that's enough to evaluate the feature. - cursor.execute("SHOW TABLE STATUS WHERE Name='INTROSPECT_TEST'") + cursor.execute("SHOW TABLE STATUS LIKE 'INTROSPECT_TEST'") result = cursor.fetchone() cursor.execute('DROP TABLE INTROSPECT_TEST') self._storage_engine = result[1] From 056b2b5f65e6b12c609dbd064cbe1e44a8730bf7 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 7 Feb 2013 06:12:25 -0500 Subject: [PATCH 176/367] [1.4.x] Fixed #19756 - Corrected a ManyToMany example and added some links and markup. Backport of 43efefae69 from master --- docs/topics/db/examples/many_to_many.txt | 47 ++++++++++++++---------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/topics/db/examples/many_to_many.txt b/docs/topics/db/examples/many_to_many.txt index 1ad89e71bf89..35cbe6548714 100644 --- a/docs/topics/db/examples/many_to_many.txt +++ b/docs/topics/db/examples/many_to_many.txt @@ -35,7 +35,7 @@ objects, and a ``Publication`` has multiple ``Article`` objects: What follows are examples of operations that can be performed using the Python API facilities. -Create a couple of Publications:: +Create a couple of ``Publications``:: >>> p1 = Publication(title='The Python Journal') >>> p1.save() @@ -44,11 +44,11 @@ Create a couple of Publications:: >>> p3 = Publication(title='Science Weekly') >>> p3.save() -Create an Article:: +Create an ``Article``:: >>> a1 = Article(headline='Django lets you build Web apps easily') -You can't associate it with a Publication until it's been saved:: +You can't associate it with a ``Publication`` until it's been saved:: >>> a1.publications.add(p1) Traceback (most recent call last): @@ -60,11 +60,11 @@ Save it! >>> a1.save() -Associate the Article with a Publication:: +Associate the ``Article`` with a ``Publication``:: >>> a1.publications.add(p1) -Create another Article, and set it to appear in both Publications:: +Create another ``Article``, and set it to appear in both ``Publications``:: >>> a2 = Article(headline='NASA uses Python') >>> a2.save() @@ -75,25 +75,26 @@ Adding a second time is OK:: >>> a2.publications.add(p3) -Adding an object of the wrong type raises TypeError:: +Adding an object of the wrong type raises :exc:`~exceptions.TypeError`:: >>> a2.publications.add(a1) Traceback (most recent call last): ... TypeError: 'Publication' instance expected -Add a Publication directly via publications.add by using keyword arguments:: +Create and add a ``Publication`` to an ``Article`` in one step using +:meth:`~django.db.models.fields.related.RelatedManager.create`:: >>> new_publication = a2.publications.create(title='Highlights for Children') -Article objects have access to their related Publication objects:: +``Article`` objects have access to their related ``Publication`` objects:: >>> a1.publications.all() [] >>> a2.publications.all() [, , , ] -Publication objects have access to their related Article objects:: +``Publication`` objects have access to their related ``Article`` objects:: >>> p2.article_set.all() [] @@ -102,7 +103,8 @@ Publication objects have access to their related Article objects:: >>> Publication.objects.get(id=4).article_set.all() [] -Many-to-many relationships can be queried using :ref:`lookups across relationships `:: +Many-to-many relationships can be queried using :ref:`lookups across +relationships `:: >>> Article.objects.filter(publications__id__exact=1) [, ] @@ -119,7 +121,8 @@ Many-to-many relationships can be queried using :ref:`lookups across relationshi >>> Article.objects.filter(publications__title__startswith="Science").distinct() [] -The count() function respects distinct() as well:: +The :meth:`~django.db.models.query.QuerySet.count` function respects +:meth:`~django.db.models.query.QuerySet.distinct` as well:: >>> Article.objects.filter(publications__title__startswith="Science").count() 2 @@ -133,7 +136,7 @@ The count() function respects distinct() as well:: [, ] Reverse m2m queries are supported (i.e., starting at the table that doesn't have -a ManyToManyField):: +a :class:`~django.db.models.ManyToManyField`):: >>> Publication.objects.filter(id__exact=1) [] @@ -163,7 +166,7 @@ involved is a little complex):: >>> Article.objects.exclude(publications=p2) [] -If we delete a Publication, its Articles won't be able to access it:: +If we delete a ``Publication``, its ``Articles`` won't be able to access it:: >>> p1.delete() >>> Publication.objects.all() @@ -172,7 +175,7 @@ If we delete a Publication, its Articles won't be able to access it:: >>> a1.publications.all() [] -If we delete an Article, its Publications won't be able to access it:: +If we delete an ``Article``, its ``Publications`` won't be able to access it:: >>> a2.delete() >>> Article.objects.all() @@ -199,7 +202,7 @@ Adding via the other end using keywords:: >>> a5.publications.all() [] -Removing publication from an article:: +Removing ``Publication`` from an ``Article``:: >>> a4.publications.remove(p2) >>> p2.article_set.all() @@ -242,7 +245,7 @@ And you can clear from the other end:: >>> p2.article_set.all() [] -Recreate the article and Publication we have deleted:: +Recreate the ``Article`` and ``Publication`` we have deleted:: >>> p1 = Publication(title='The Python Journal') >>> p1.save() @@ -250,7 +253,8 @@ Recreate the article and Publication we have deleted:: >>> a2.save() >>> a2.publications.add(p1, p2, p3) -Bulk delete some Publications - references to deleted publications should go:: +Bulk delete some ``Publications`` - references to deleted publications should +go:: >>> Publication.objects.filter(title__startswith='Science').delete() >>> Publication.objects.all() @@ -267,15 +271,18 @@ Bulk delete some articles - references to deleted objects should go:: [] >>> q.delete() -After the delete, the QuerySet cache needs to be cleared, and the referenced -objects should be gone:: +After the :meth:`~django.db.models.query.QuerySet.delete`, the +:class:`~django.db.models.query.QuerySet` cache needs to be cleared, and the +referenced objects should be gone:: >>> print q [] >>> p1.article_set.all() [] -An alternate to calling clear() is to assign the empty set:: +An alternate to calling +:meth:`~django.db.models.fields.related.RelatedManager.clear` is to assign the +empty set:: >>> p1.article_set = [] >>> p1.article_set.all() From 498a5de07bd325628c9bd7a804144a9f714ca721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Sun, 10 Feb 2013 17:06:52 +0200 Subject: [PATCH 177/367] [1.4.x] Fixed #19645 -- Added tests for TransactionMiddleware Backpatch of f556df90be995a83b979cf875705d98521ab4dc7. Backpatching these tests so that it will be easier to backpatch the fix for #19707. --- tests/regressiontests/middleware/models.py | 12 +++++- tests/regressiontests/middleware/tests.py | 49 +++++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/tests/regressiontests/middleware/models.py b/tests/regressiontests/middleware/models.py index 71abcc51987f..1e14da4683eb 100644 --- a/tests/regressiontests/middleware/models.py +++ b/tests/regressiontests/middleware/models.py @@ -1 +1,11 @@ -# models.py file for tests to run. +from django.db import models + + +class Band(models.Model): + name = models.CharField(max_length=100) + + class Meta: + ordering = ('name',) + + def __unicode__(self): + return self.name diff --git a/tests/regressiontests/middleware/tests.py b/tests/regressiontests/middleware/tests.py index 6a1896a266bb..00e4e3ecc19f 100644 --- a/tests/regressiontests/middleware/tests.py +++ b/tests/regressiontests/middleware/tests.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import, with_statement import gzip import re @@ -7,15 +8,19 @@ from django.conf import settings from django.core import mail +from django.db import transaction from django.http import HttpRequest from django.http import HttpResponse from django.middleware.clickjacking import XFrameOptionsMiddleware from django.middleware.common import CommonMiddleware from django.middleware.http import ConditionalGetMiddleware from django.middleware.gzip import GZipMiddleware -from django.test import TestCase, RequestFactory +from django.middleware.transaction import TransactionMiddleware +from django.test import TransactionTestCase, TestCase, RequestFactory from django.test.utils import override_settings +from .models import Band + class CommonMiddlewareTest(TestCase): def setUp(self): self.append_slash = settings.APPEND_SLASH @@ -613,3 +618,45 @@ def test_compress_response(self): ETagGZipMiddlewareTest = override_settings( USE_ETAGS=True, )(ETagGZipMiddlewareTest) + +class TransactionMiddlewareTest(TransactionTestCase): + """ + Test the transaction middleware. + """ + def setUp(self): + self.request = HttpRequest() + self.request.META = { + 'SERVER_NAME': 'testserver', + 'SERVER_PORT': 80, + } + self.request.path = self.request.path_info = "/" + self.response = HttpResponse() + self.response.status_code = 200 + + def test_request(self): + TransactionMiddleware().process_request(self.request) + self.assertTrue(transaction.is_managed()) + + def test_managed_response(self): + transaction.enter_transaction_management() + transaction.managed(True) + Band.objects.create(name='The Beatles') + self.assertTrue(transaction.is_dirty()) + TransactionMiddleware().process_response(self.request, self.response) + self.assertFalse(transaction.is_dirty()) + self.assertEqual(Band.objects.count(), 1) + + def test_unmanaged_response(self): + transaction.managed(False) + TransactionMiddleware().process_response(self.request, self.response) + self.assertFalse(transaction.is_managed()) + self.assertFalse(transaction.is_dirty()) + + def test_exception(self): + transaction.enter_transaction_management() + transaction.managed(True) + Band.objects.create(name='The Beatles') + self.assertTrue(transaction.is_dirty()) + TransactionMiddleware().process_exception(self.request, None) + self.assertEqual(Band.objects.count(), 0) + self.assertFalse(transaction.is_dirty()) From 9918b3f50212bfb408eebc8f9965beb061baf840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Tue, 5 Feb 2013 23:52:29 +0200 Subject: [PATCH 178/367] [1.4.x] Fixed #19707 -- Reset transaction state after requests Backpatch of a4e97cf315142e61bb4bc3ed8259b95d8586d09c. --- django/db/__init__.py | 13 +++++++-- django/db/backends/__init__.py | 11 +++++++ django/db/transaction.py | 15 ++++++++++ django/db/utils.py | 3 ++ django/middleware/transaction.py | 21 +++++++++++++- django/test/testcases.py | 3 ++ tests/regressiontests/middleware/tests.py | 22 +++++++++++++- tests/regressiontests/requests/tests.py | 35 +++++++++++++++++++++++ 8 files changed, 119 insertions(+), 4 deletions(-) diff --git a/django/db/__init__.py b/django/db/__init__.py index 26c7add0af53..1340839c1344 100644 --- a/django/db/__init__.py +++ b/django/db/__init__.py @@ -42,8 +42,17 @@ def __setattr__(self, name, value): # Register an event that closes the database connection # when a Django request is finished. def close_connection(**kwargs): - for conn in connections.all(): - conn.close() + # Avoid circular imports + from django.db import transaction + for conn in connections: + try: + transaction.abort(conn) + connections[conn].close() + except Exception: + # The connection's state is unknown, so it has to be + # abandoned. This could happen for example if the network + # connection has a failure. + del connections[conn] signals.request_finished.connect(close_connection) # Register an event that resets connection.queries diff --git a/django/db/backends/__init__.py b/django/db/backends/__init__.py index 14e9c4abeb44..9c7936ea3bd9 100644 --- a/django/db/backends/__init__.py +++ b/django/db/backends/__init__.py @@ -83,6 +83,17 @@ def _savepoint_commit(self, sid): return self.cursor().execute(self.ops.savepoint_commit_sql(sid)) + def abort(self): + """ + Roll back any ongoing transaction and clean the transaction state + stack. + """ + if self._dirty: + self._rollback() + self._dirty = False + while self.transaction_state: + self.leave_transaction_management() + def enter_transaction_management(self, managed=True): """ Enters transaction management for a running thread. It must be balanced with diff --git a/django/db/transaction.py b/django/db/transaction.py index 4ecd2d1f0849..48166d826b7e 100644 --- a/django/db/transaction.py +++ b/django/db/transaction.py @@ -25,6 +25,21 @@ class TransactionManagementError(Exception): """ pass +def abort(using=None): + """ + Roll back any ongoing transactions and clean the transaction management + state of the connection. + + This method is to be used only in cases where using balanced + leave_transaction_management() calls isn't possible. For example after a + request has finished, the transaction state isn't known, yet the connection + must be cleaned up for the next request. + """ + if using is None: + using = DEFAULT_DB_ALIAS + connection = connections[using] + connection.abort() + def enter_transaction_management(managed=True, using=None): """ Enters transaction management for a running thread. It must be balanced with diff --git a/django/db/utils.py b/django/db/utils.py index 3f5b86ee121f..2ef6e3f68701 100644 --- a/django/db/utils.py +++ b/django/db/utils.py @@ -97,6 +97,9 @@ def __getitem__(self, alias): def __setitem__(self, key, value): setattr(self._connections, key, value) + def __delitem__(self, key): + delattr(self._connections, key) + def __iter__(self): return iter(self.databases) diff --git a/django/middleware/transaction.py b/django/middleware/transaction.py index 96b1538d9dfc..4440f377a761 100644 --- a/django/middleware/transaction.py +++ b/django/middleware/transaction.py @@ -15,6 +15,10 @@ def process_request(self, request): def process_exception(self, request, exception): """Rolls back the database and leaves transaction management""" if transaction.is_dirty(): + # This rollback might fail because of network failure for example. + # If rollback isn't possible it is impossible to clean the + # connection's state. So leave the connection in dirty state and + # let request_finished signal deal with cleaning the connection. transaction.rollback() transaction.leave_transaction_management() @@ -22,6 +26,21 @@ def process_response(self, request, response): """Commits and leaves transaction management.""" if transaction.is_managed(): if transaction.is_dirty(): - transaction.commit() + # Note: it is possible that the commit fails. If the reason is + # closed connection or some similar reason, then there is + # little hope to proceed nicely. However, in some cases ( + # deferred foreign key checks for exampl) it is still possible + # to rollback(). + try: + transaction.commit() + except Exception: + # If the rollback fails, the transaction state will be + # messed up. It doesn't matter, the connection will be set + # to clean state after the request finishes. And, we can't + # clean the state here properly even if we wanted to, the + # connection is in transaction but we can't rollback... + transaction.rollback() + transaction.leave_transaction_management() + raise transaction.leave_transaction_management() return response diff --git a/django/test/testcases.py b/django/test/testcases.py index 1f451877ac85..8e794891c927 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -63,6 +63,7 @@ def to_list(value): real_enter_transaction_management = transaction.enter_transaction_management real_leave_transaction_management = transaction.leave_transaction_management real_managed = transaction.managed +real_abort = transaction.abort def nop(*args, **kwargs): return @@ -73,6 +74,7 @@ def disable_transaction_methods(): transaction.enter_transaction_management = nop transaction.leave_transaction_management = nop transaction.managed = nop + transaction.abort = nop def restore_transaction_methods(): transaction.commit = real_commit @@ -80,6 +82,7 @@ def restore_transaction_methods(): transaction.enter_transaction_management = real_enter_transaction_management transaction.leave_transaction_management = real_leave_transaction_management transaction.managed = real_managed + transaction.abort = real_abort def assert_and_parse_html(self, html, user_msg, msg): diff --git a/tests/regressiontests/middleware/tests.py b/tests/regressiontests/middleware/tests.py index 00e4e3ecc19f..138ee50e4382 100644 --- a/tests/regressiontests/middleware/tests.py +++ b/tests/regressiontests/middleware/tests.py @@ -8,7 +8,8 @@ from django.conf import settings from django.core import mail -from django.db import transaction +from django.db import (transaction, connections, DEFAULT_DB_ALIAS, + IntegrityError) from django.http import HttpRequest from django.http import HttpResponse from django.middleware.clickjacking import XFrameOptionsMiddleware @@ -660,3 +661,22 @@ def test_exception(self): TransactionMiddleware().process_exception(self.request, None) self.assertEqual(Band.objects.count(), 0) self.assertFalse(transaction.is_dirty()) + + def test_failing_commit(self): + # It is possible that connection.commit() fails. Check that + # TransactionMiddleware handles such cases correctly. + try: + def raise_exception(): + raise IntegrityError() + connections[DEFAULT_DB_ALIAS].commit = raise_exception + transaction.enter_transaction_management() + transaction.managed(True) + Band.objects.create(name='The Beatles') + self.assertTrue(transaction.is_dirty()) + with self.assertRaises(IntegrityError): + TransactionMiddleware().process_response(self.request, None) + self.assertEqual(Band.objects.count(), 0) + self.assertFalse(transaction.is_dirty()) + self.assertFalse(transaction.is_managed()) + finally: + del connections[DEFAULT_DB_ALIAS].commit diff --git a/tests/regressiontests/requests/tests.py b/tests/regressiontests/requests/tests.py index caa25aea218e..aba0461c79ea 100644 --- a/tests/regressiontests/requests/tests.py +++ b/tests/regressiontests/requests/tests.py @@ -6,11 +6,14 @@ from datetime import datetime, timedelta from StringIO import StringIO +from django.db import connection, connections, DEFAULT_DB_ALIAS +from django.core import signals from django.conf import settings from django.core.handlers.modpython import ModPythonRequest from django.core.exceptions import SuspiciousOperation from django.core.handlers.wsgi import WSGIRequest, LimitedStream from django.http import HttpRequest, HttpResponse, parse_cookie, build_request_repr, UnreadablePostError +from django.test import TransactionTestCase from django.test.utils import get_warnings_state, restore_warnings_state from django.utils import unittest from django.utils.http import cookie_date @@ -530,3 +533,35 @@ def read(self, len=0): with self.assertRaises(UnreadablePostError): request.raw_post_data + +class TransactionRequestTests(TransactionTestCase): + def test_request_finished_db_state(self): + # Make sure there is an open connection + connection.cursor() + connection.enter_transaction_management() + connection.managed(True) + signals.request_finished.send(sender=self.__class__) + # In-memory sqlite doesn't actually close connections. + if connection.vendor != 'sqlite': + self.assertIs(connection.connection, None) + self.assertEqual(len(connection.transaction_state), 0) + + @unittest.skipIf(connection.vendor == 'sqlite', + 'This test will close the connection, in-memory ' + 'sqlite connections must not be closed.') + def test_request_finished_failed_connection(self): + conn = connections[DEFAULT_DB_ALIAS] + conn.enter_transaction_management() + conn.managed(True) + conn.set_dirty() + # Test that the rollback doesn't succeed (for example network failure + # could cause this). + def fail_horribly(): + raise Exception("Horrible failure!") + conn._rollback = fail_horribly + signals.request_finished.send(sender=self.__class__) + # As even rollback wasn't possible the connection wrapper itself was + # abandoned. Accessing the connections[alias] will create a new + # connection wrapper, whch must be different than the original one. + self.assertIsNot(conn, connections[DEFAULT_DB_ALIAS]) + self.assertEqual(len(connection.transaction_state), 0) From 209f174e58b8d621a06c701281e547a659d9d99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Sun, 10 Feb 2013 21:49:03 +0200 Subject: [PATCH 179/367] [1.4.x] Made custom m2m fields without through easier to use The change in f105fbe52b21da206bfbaedf0e92326667d7b2d4 made through=None m2m fields fail in cases where they worked before. It isn't possible to create such fields using public APIs. The fix is trivial, so it seems worth fixing this for custom m2m field users. This is not a backport from master. Master has gotten enough other changes to related fields internal API that this fix alone isn't enough to do any good. --- django/db/models/fields/related.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 28e8e06c1866..e5f12fe1188d 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -544,12 +544,14 @@ def __init__(self, model=None, query_field_name=None, instance=None, symmetrical "a many-to-many relationship can be used." % instance.__class__.__name__) - def _get_fk_val(self, obj, field_name): """ Returns the correct value for this relationship's foreign key. This might be something else than pk value when to_field is used. """ + if not self.through: + # Make custom m2m fields with no through model defined usable. + return obj.pk fk = self.through._meta.get_field(field_name) if fk.rel.field_name and fk.rel.field_name != fk.rel.to._meta.pk.attname: attname = fk.rel.get_related_field().get_attname() From b4fb448f8387d92832fc70ac58de69c73d5f1729 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Mon, 11 Feb 2013 08:40:11 +0100 Subject: [PATCH 180/367] Fixed WSGIPythonPath instruction in deployment docs Partial backport of 3abf6105b6 from master. Refs #19042. --- docs/howto/deployment/wsgi/modwsgi.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/howto/deployment/wsgi/modwsgi.txt b/docs/howto/deployment/wsgi/modwsgi.txt index d669f6d85228..2562556ca24d 100644 --- a/docs/howto/deployment/wsgi/modwsgi.txt +++ b/docs/howto/deployment/wsgi/modwsgi.txt @@ -61,10 +61,10 @@ Using a virtualenv If you install your project's Python dependencies inside a `virtualenv`_, you'll need to add the path to this virtualenv's ``site-packages`` directory to -your Python path as well. To do this, you can add another line to your -Apache configuration:: +your Python path as well. To do this, add an additional path to your +`WSGIPythonPath` directive, with multiple paths separated by a colon:: - WSGIPythonPath /path/to/your/venv/lib/python2.X/site-packages + WSGIPythonPath /path/to/mysite.com:/path/to/your/venv/lib/python2.X/site-packages Make sure you give the correct path to your virtualenv, and replace ``python2.X`` with the correct Python version (e.g. ``python2.7``). From dec7dd99f095f938bc4306a0260d5b131935ad82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Tue, 12 Feb 2013 23:11:22 +0200 Subject: [PATCH 181/367] [1.4.x] Removed try-except in django.db.close_connection() The reason was that the except clause needed to remove a connection from the django.db.connections dict, but other parts of Django do not expect this to happen. In addition the except clause was silently swallowing the exception messages. Refs #19707, special thanks to Carl Meyer for pointing out that this approach should be taken. --- django/db/__init__.py | 13 +++++-------- django/db/utils.py | 3 --- tests/regressiontests/requests/tests.py | 13 +++++++++---- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/django/db/__init__.py b/django/db/__init__.py index 1340839c1344..605c3a20d4f4 100644 --- a/django/db/__init__.py +++ b/django/db/__init__.py @@ -45,14 +45,11 @@ def close_connection(**kwargs): # Avoid circular imports from django.db import transaction for conn in connections: - try: - transaction.abort(conn) - connections[conn].close() - except Exception: - # The connection's state is unknown, so it has to be - # abandoned. This could happen for example if the network - # connection has a failure. - del connections[conn] + # If an error happens here the connection will be left in broken + # state. Once a good db connection is again available, the + # connection state will be cleaned up. + transaction.abort(conn) + connections[conn].close() signals.request_finished.connect(close_connection) # Register an event that resets connection.queries diff --git a/django/db/utils.py b/django/db/utils.py index 2ef6e3f68701..3f5b86ee121f 100644 --- a/django/db/utils.py +++ b/django/db/utils.py @@ -97,9 +97,6 @@ def __getitem__(self, alias): def __setitem__(self, key, value): setattr(self._connections, key, value) - def __delitem__(self, key): - delattr(self._connections, key) - def __iter__(self): return iter(self.databases) diff --git a/tests/regressiontests/requests/tests.py b/tests/regressiontests/requests/tests.py index aba0461c79ea..5927cbb8bc8c 100644 --- a/tests/regressiontests/requests/tests.py +++ b/tests/regressiontests/requests/tests.py @@ -559,9 +559,14 @@ def test_request_finished_failed_connection(self): def fail_horribly(): raise Exception("Horrible failure!") conn._rollback = fail_horribly + try: + with self.assertRaises(Exception): + signals.request_finished.send(sender=self.__class__) + # The connection's state wasn't cleaned up + self.assertTrue(len(connection.transaction_state), 1) + finally: + del conn._rollback + # The connection will be cleaned on next request where the conn + # works again. signals.request_finished.send(sender=self.__class__) - # As even rollback wasn't possible the connection wrapper itself was - # abandoned. Accessing the connections[alias] will create a new - # connection wrapper, whch must be different than the original one. - self.assertIsNot(conn, connections[DEFAULT_DB_ALIAS]) self.assertEqual(len(connection.transaction_state), 0) From 9eb7d59665972690bea790fd1ed12eeb142c0ee4 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 12 Feb 2013 20:04:15 -0500 Subject: [PATCH 182/367] [1.4.x] Fixed #19815 - Removed an unused import in tutorial 3. Thanks pedro.calcao@ for the report. --- docs/intro/tutorial03.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro/tutorial03.txt b/docs/intro/tutorial03.txt index 5ed927a9dfe4..4a0c900137e2 100644 --- a/docs/intro/tutorial03.txt +++ b/docs/intro/tutorial03.txt @@ -516,7 +516,7 @@ URLconf by removing the leading "polls/" from each line, and removing the lines registering the admin site. Your ``polls/urls.py`` file should now look like this:: - from django.conf.urls import patterns, include, url + from django.conf.urls import patterns, url urlpatterns = patterns('polls.views', url(r'^$', 'index'), From 3d6388941d231e7ae1b5a53cdf2e401b704744b5 Mon Sep 17 00:00:00 2001 From: Alex Hunley Date: Sat, 16 Feb 2013 14:30:55 -0500 Subject: [PATCH 183/367] [1.4.x] Fixed #19719 - Removed misleading example from ModelForm documentation Backport of 976dc07baf from master --- docs/topics/forms/modelforms.txt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index 091073fb0c7f..5ca33188721e 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -225,11 +225,6 @@ supplied, ``save()`` will update that instance. If it's not supplied, # Save a new Article object from the form's data. >>> new_article = f.save() - # Create a form to edit an existing Article. - >>> a = Article.objects.get(pk=1) - >>> f = ArticleForm(instance=a) - >>> f.save() - # Create a form to edit an existing Article, but use # POST data to populate the form. >>> a = Article.objects.get(pk=1) From 83e512fa6e45d2c9e63735bb0c3cd8f1fcd2e616 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 16 Feb 2013 18:23:39 -0500 Subject: [PATCH 184/367] [1.4.x] Fixed #19812 - Removed a duplicate phrase in the widget docs. Thanks diegueus9 for the report and itsallvoodoo for the draft patch. Backport of 7a80904b00 from master --- docs/ref/forms/widgets.txt | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index 60ba00c0ca7d..a745f1b11d84 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -278,15 +278,10 @@ foundation for custom widgets. * A single value (e.g., a string) that is the "compressed" representation of a ``list`` of values. - If `value` is a list, output of :meth:`~MultiWidget.render` will be a - concatenation of rendered child widgets. If `value` is not a list, it - will be first processed by the method :meth:`~MultiWidget.decompress()` - to create the list and then processed as above. - - In the second case -- i.e., if the value is *not* a list -- - ``render()`` will first decompress the value into a ``list`` before - rendering it. It does so by calling the ``decompress()`` method, which - :class:`MultiWidget`'s subclasses must implement (see above). + If ``value`` is a list, the output of :meth:`~MultiWidget.render` will + be a concatenation of rendered child widgets. If ``value`` is not a + list, it will first be processed by the method + :meth:`~MultiWidget.decompress()` to create the list and then rendered. When ``render()`` executes its HTML rendering, each value in the list is rendered with the corresponding widget -- the first value is From 57b62a74cb44ac3cf8b1a9d4cf75bfe953208814 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 16 Feb 2013 18:31:54 -0500 Subject: [PATCH 185/367] [1.4.x] Fixed #19824 - Corrected the class described for Field.primary_key from IntegerField to AutoField. Thanks Keryn Knight. Backport of 218bbef0c4 from master --- docs/ref/models/fields.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index b186a461c7b8..cb359fba8d5a 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -252,8 +252,8 @@ Alternatively you can use plain text and If ``True``, this field is the primary key for the model. -If you don't specify ``primary_key=True`` for any fields in your model, Django -will automatically add an :class:`IntegerField` to hold the primary key, so you +If you don't specify ``primary_key=True`` for any field in your model, Django +will automatically add an :class:`AutoField` to hold the primary key, so you don't need to set ``primary_key=True`` on any of your fields unless you want to override the default primary-key behavior. For more, see :ref:`automatic-primary-key-fields`. From 9936fdb11d0bbf0bd242f259bfb97bbf849d16f8 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Sat, 9 Feb 2013 11:32:51 -0700 Subject: [PATCH 186/367] [1.4.x] Added ALLOWED_HOSTS setting for HTTP host header validation. This is a security fix; disclosure and advisory coming shortly. --- django/conf/global_settings.py | 4 + .../project_template/project_name/settings.py | 4 + django/contrib/auth/tests/views.py | 1 + django/contrib/contenttypes/tests.py | 2 + django/contrib/sites/tests.py | 2 + django/http/__init__.py | 51 +++- django/test/utils.py | 6 + docs/ref/settings.txt | 36 +++ docs/releases/1.4.4.txt | 39 +++ docs/releases/index.txt | 1 + docs/topics/security.txt | 68 ++--- tests/regressiontests/csrf_tests/tests.py | 4 + tests/regressiontests/requests/tests.py | 271 +++++++++--------- 13 files changed, 314 insertions(+), 175 deletions(-) create mode 100644 docs/releases/1.4.4.txt diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index bd85c121b8ec..026e39b7c94d 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -29,6 +29,10 @@ # * Receive x-headers INTERNAL_IPS = () +# Hosts/domain names that are valid for this site. +# "*" matches anything, ".example.com" matches example.com and all subdomains +ALLOWED_HOSTS = ['*'] + # Local time zone for this installation. All choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name (although not all # systems may support all possibilities). When USE_TZ is True, this is diff --git a/django/conf/project_template/project_name/settings.py b/django/conf/project_template/project_name/settings.py index 0eccc4eaf5c6..5780aca0124e 100644 --- a/django/conf/project_template/project_name/settings.py +++ b/django/conf/project_template/project_name/settings.py @@ -20,6 +20,10 @@ } } +# Hosts/domain names that are valid for this site; required if DEBUG is False +# See https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#allowed-hosts +ALLOWED_HOSTS = [] + # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py index d295bb8c1082..603d380e9da5 100644 --- a/django/contrib/auth/tests/views.py +++ b/django/contrib/auth/tests/views.py @@ -107,6 +107,7 @@ def test_email_found_custom_from(self): self.assertEqual(len(mail.outbox), 1) self.assertEqual("staffmember@example.com", mail.outbox[0].from_email) + @override_settings(ALLOWED_HOSTS=['adminsite.com']) def test_admin_reset(self): "If the reset view is marked as being for admin, the HTTP_HOST header is used for a domain override." response = self.client.post('/admin_password_reset/', diff --git a/django/contrib/contenttypes/tests.py b/django/contrib/contenttypes/tests.py index 3b7906c8129d..66226a78b888 100644 --- a/django/contrib/contenttypes/tests.py +++ b/django/contrib/contenttypes/tests.py @@ -9,6 +9,7 @@ from django.http import HttpRequest, Http404 from django.test import TestCase from django.utils.encoding import smart_str +from django.test.utils import override_settings class FooWithoutUrl(models.Model): @@ -114,6 +115,7 @@ def test_get_for_models_full_cache(self): FooWithUrl: ContentType.objects.get_for_model(FooWithUrl), }) + @override_settings(ALLOWED_HOSTS=['example.com']) def test_shortcut_view(self): """ Check that the shortcut view (used for the admin "view on site" diff --git a/django/contrib/sites/tests.py b/django/contrib/sites/tests.py index 828badb38674..1fd52e657e71 100644 --- a/django/contrib/sites/tests.py +++ b/django/contrib/sites/tests.py @@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.http import HttpRequest from django.test import TestCase +from django.test.utils import override_settings class SitesFrameworkTests(TestCase): @@ -39,6 +40,7 @@ def test_site_cache(self): site = Site.objects.get_current() self.assertEqual(u"Example site", site.name) + @override_settings(ALLOWED_HOSTS=['example.com']) def test_get_current_site(self): # Test that the correct Site object is returned request = HttpRequest() diff --git a/django/http/__init__.py b/django/http/__init__.py index da993eb8d3ba..4f5fbe6cf31d 100644 --- a/django/http/__init__.py +++ b/django/http/__init__.py @@ -215,11 +215,12 @@ def get_host(self): if server_port != (self.is_secure() and '443' or '80'): host = '%s:%s' % (host, server_port) - # Disallow potentially poisoned hostnames. - if not host_validation_re.match(host.lower()): - raise SuspiciousOperation('Invalid HTTP_HOST header: %s' % host) - - return host + allowed_hosts = ['*'] if settings.DEBUG else settings.ALLOWED_HOSTS + if validate_host(host, allowed_hosts): + return host + else: + raise SuspiciousOperation( + "Invalid HTTP_HOST header (you may need to set ALLOWED_HOSTS): %s" % host) def get_full_path(self): # RFC 3986 requires query string arguments to be in the ASCII range. @@ -799,3 +800,43 @@ def str_to_unicode(s, encoding): else: return s +def validate_host(host, allowed_hosts): + """ + Validate the given host header value for this site. + + Check that the host looks valid and matches a host or host pattern in the + given list of ``allowed_hosts``. Any pattern beginning with a period + matches a domain and all its subdomains (e.g. ``.example.com`` matches + ``example.com`` and any subdomain), ``*`` matches anything, and anything + else must match exactly. + + Return ``True`` for a valid host, ``False`` otherwise. + + """ + # All validation is case-insensitive + host = host.lower() + + # Basic sanity check + if not host_validation_re.match(host): + return False + + # Validate only the domain part. + if host[-1] == ']': + # It's an IPv6 address without a port. + domain = host + else: + domain = host.rsplit(':', 1)[0] + + for pattern in allowed_hosts: + pattern = pattern.lower() + match = ( + pattern == '*' or + pattern.startswith('.') and ( + domain.endswith(pattern) or domain == pattern[1:] + ) or + pattern == domain + ) + if match: + return True + + return False diff --git a/django/test/utils.py b/django/test/utils.py index ed5ab590a78f..6d6b6e177fc0 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -75,6 +75,9 @@ def setup_test_environment(): mail.original_email_backend = settings.EMAIL_BACKEND settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' + settings._original_allowed_hosts = settings.ALLOWED_HOSTS + settings.ALLOWED_HOSTS = ['*'] + mail.outbox = [] deactivate() @@ -93,6 +96,9 @@ def teardown_test_environment(): settings.EMAIL_BACKEND = mail.original_email_backend del mail.original_email_backend + settings.ALLOWED_HOSTS = settings._original_allowed_hosts + del settings._original_allowed_hosts + del mail.outbox diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index d7b100f0b9ba..f992eef3e7f4 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -68,6 +68,42 @@ of (Full name, email address). Example:: Note that Django will email *all* of these people whenever an error happens. See :doc:`/howto/error-reporting` for more information. +.. setting:: ALLOWED_HOSTS + +ALLOWED_HOSTS +------------- + +Default: ``['*']`` + +A list of strings representing the host/domain names that this Django site can +serve. This is a security measure to prevent an attacker from poisoning caches +and password reset emails with links to malicious hosts by submitting requests +with a fake HTTP ``Host`` header, which is possible even under many +seemingly-safe webserver configurations. + +Values in this list can be fully qualified names (e.g. ``'www.example.com'``), +in which case they will be matched against the request's ``Host`` header +exactly (case-insensitive, not including port). A value beginning with a period +can be used as a subdomain wildcard: ``'.example.com'`` will match +``example.com``, ``www.example.com``, and any other subdomain of +``example.com``. A value of ``'*'`` will match anything; in this case you are +responsible to provide your own validation of the ``Host`` header (perhaps in a +middleware; if so this middleware must be listed first in +:setting:`MIDDLEWARE_CLASSES`). + +If the ``Host`` header (or ``X-Forwarded-Host`` if +:setting:`USE_X_FORWARDED_HOST` is enabled) does not match any value in this +list, the :meth:`django.http.HttpRequest.get_host()` method will raise +:exc:`~django.core.exceptions.SuspiciousOperation`. + +When :setting:`DEBUG` is ``True`` or when running tests, host validation is +disabled; any host will be accepted. Thus it's usually only necessary to set it +in production. + +This validation only applies via :meth:`~django.http.HttpRequest.get_host()`; +if your code accesses the ``Host`` header directly from ``request.META`` you +are bypassing this security protection. + .. setting:: ALLOWED_INCLUDE_ROOTS ALLOWED_INCLUDE_ROOTS diff --git a/docs/releases/1.4.4.txt b/docs/releases/1.4.4.txt new file mode 100644 index 000000000000..3c5513bb8f16 --- /dev/null +++ b/docs/releases/1.4.4.txt @@ -0,0 +1,39 @@ +========================== +Django 1.4.4 release notes +========================== + +*February 19, 2013* + +This is the fourth bugfix/security release in the Django 1.4 series. + +Host header poisoning +--------------------- + +Some parts of Django -- independent of end-user-written applications -- make +use of full URLs, including domain name, which are generated from the HTTP Host +header. Django's documentation has for some time contained notes advising users +on how to configure webservers to ensure that only valid Host headers can reach +the Django application. However, it has been reported to us that even with the +recommended webserver configurations there are still techniques available for +tricking many common webservers into supplying the application with an +incorrect and possibly malicious Host header. + +For this reason, Django 1.4.4 adds a new setting, ``ALLOWED_HOSTS``, containing +an explicit list of valid host/domain names for this site. A request with a +Host header not matching an entry in this list will raise +``SuspiciousOperation`` if ``request.get_host()`` is called. For full details +see the documentation for the :setting:`ALLOWED_HOSTS` setting. + +The default value for this setting in Django 1.4.4 is `['*']` (matching any +host), for backwards-compatibility, but we strongly encourage all sites to set +a more restrictive value. + +This host validation is disabled when ``DEBUG`` is ``True`` or when running tests. + + +Other bugfixes and changes +========================== + +* Changed a SQL command syntax to be MySQL 4 compatible (#19702). +* Added backwards-compatibility with old unsalted MD5 passwords (#18144). +* Numerous documentation improvements and fixes. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 0b465a6d80d2..3571e0312674 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -20,6 +20,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.4 1.4.2 1.4.1 1.4 diff --git a/docs/topics/security.txt b/docs/topics/security.txt index 0b5112803c37..2a784bce8509 100644 --- a/docs/topics/security.txt +++ b/docs/topics/security.txt @@ -149,48 +149,40 @@ server, there are some additional steps you may need: .. _additional-security-topics: -Host headers and virtual hosting -================================ +Host header validation +====================== -Django uses the ``Host`` header provided by the client to construct URLs -in certain cases. While these values are sanitized to prevent Cross -Site Scripting attacks, they can be used for Cross-Site Request -Forgery and cache poisoning attacks in some circumstances. We -recommend you ensure your Web server is configured such that: +Django uses the ``Host`` header provided by the client to construct URLs in +certain cases. While these values are sanitized to prevent Cross Site Scripting +attacks, a fake ``Host`` value can be used for Cross-Site Request Forgery, +cache poisoning attacks, and poisoning links in emails. - * It always validates incoming HTTP ``Host`` headers against the expected - host name. - * Disallows requests with no ``Host`` header. - * Is *not* configured with a catch-all virtual host that forwards requests - to a Django application. +Because even seemingly-secure webserver configurations are susceptible to fake +``Host`` headers, Django validates ``Host`` headers against the +:setting:`ALLOWED_HOSTS` setting in the +:meth:`django.http.HttpRequest.get_host()` method. -Additionally, as of 1.3.1, Django requires you to explicitly enable support for -the ``X-Forwarded-Host`` header if your configuration requires it. - -Configuration for Apache ------------------------- - -The easiest way to get the described behavior in Apache is as follows. Create -a `virtual host`_ using the ServerName_ and ServerAlias_ directives to restrict -the domains Apache reacts to. Please keep in mind that while the directives do -support ports the match is only performed against the hostname. This means that -the ``Host`` header could still contain a port pointing to another webserver on -the same machine. The next step is to make sure that your newly created virtual -host is not also the default virtual host. Apache uses the first virtual host -found in the configuration file as default virtual host. As such you have to -ensure that you have another virtual host which will act as catch-all virtual -host. Just add one if you do not have one already, there is nothing special -about it aside from ensuring it is the first virtual host in the configuration -file. Debian/Ubuntu users usually don't have to take any action, since Apache -ships with a default virtual host in ``sites-available`` which is linked into -``sites-enabled`` as ``000-default`` and included from ``apache2.conf``. Just -make sure not to name your site ``000-abc``, since files are included in -alphabetical order. - -.. _virtual host: http://httpd.apache.org/docs/2.2/vhosts/ -.. _ServerName: http://httpd.apache.org/docs/2.2/mod/core.html#servername -.. _ServerAlias: http://httpd.apache.org/docs/2.2/mod/core.html#serveralias +This validation only applies via :meth:`~django.http.HttpRequest.get_host()`; +if your code accesses the ``Host`` header directly from ``request.META`` you +are bypassing this security protection. + +For more details see the full :setting:`ALLOWED_HOSTS` documentation. + +.. warning:: + Previous versions of this document recommended configuring your webserver to + ensure it validates incoming HTTP ``Host`` headers. While this is still + recommended, in many common webservers a configuration that seems to + validate the ``Host`` header may not in fact do so. For instance, even if + Apache is configured such that your Django site is served from a non-default + virtual host with the ``ServerName`` set, it is still possible for an HTTP + request to match this virtual host and supply a fake ``Host`` header. Thus, + Django now requires that you set :setting:`ALLOWED_HOSTS` explicitly rather + than relying on webserver configuration. + +Additionally, as of 1.3.1, Django requires you to explicitly enable support for +the ``X-Forwarded-Host`` header (via the :setting:`USE_X_FORWARDED_HOST` +setting) if your configuration requires it. diff --git a/tests/regressiontests/csrf_tests/tests.py b/tests/regressiontests/csrf_tests/tests.py index 71400ead8933..a605134fba89 100644 --- a/tests/regressiontests/csrf_tests/tests.py +++ b/tests/regressiontests/csrf_tests/tests.py @@ -7,6 +7,7 @@ from django.middleware.csrf import CsrfViewMiddleware, CSRF_KEY_LENGTH from django.template import RequestContext, Template from django.test import TestCase +from django.test.utils import override_settings from django.views.decorators.csrf import csrf_exempt, requires_csrf_token, ensure_csrf_cookie @@ -267,6 +268,7 @@ def test_token_node_with_new_csrf_cookie(self): csrf_cookie = resp2.cookies[settings.CSRF_COOKIE_NAME] self._check_token_present(resp, csrf_id=csrf_cookie.value) + @override_settings(ALLOWED_HOSTS=['www.example.com']) def test_https_bad_referer(self): """ Test that a POST HTTPS request with a bad referer is rejected @@ -279,6 +281,7 @@ def test_https_bad_referer(self): self.assertNotEqual(None, req2) self.assertEqual(403, req2.status_code) + @override_settings(ALLOWED_HOSTS=['www.example.com']) def test_https_good_referer(self): """ Test that a POST HTTPS request with a good referer is accepted @@ -290,6 +293,7 @@ def test_https_good_referer(self): req2 = CsrfViewMiddleware().process_view(req, post_form_view, (), {}) self.assertEqual(None, req2) + @override_settings(ALLOWED_HOSTS=['www.example.com']) def test_https_good_referer_2(self): """ Test that a POST HTTPS request with a good referer is accepted diff --git a/tests/regressiontests/requests/tests.py b/tests/regressiontests/requests/tests.py index 5927cbb8bc8c..2c9873c9e28f 100644 --- a/tests/regressiontests/requests/tests.py +++ b/tests/regressiontests/requests/tests.py @@ -14,7 +14,7 @@ from django.core.handlers.wsgi import WSGIRequest, LimitedStream from django.http import HttpRequest, HttpResponse, parse_cookie, build_request_repr, UnreadablePostError from django.test import TransactionTestCase -from django.test.utils import get_warnings_state, restore_warnings_state +from django.test.utils import get_warnings_state, restore_warnings_state, override_settings from django.utils import unittest from django.utils.http import cookie_date from django.utils.timezone import utc @@ -109,161 +109,168 @@ def test_httprequest_location(self): self.assertEqual(request.build_absolute_uri(location="/path/with:colons"), 'http://www.example.com/path/with:colons') + @override_settings( + USE_X_FORWARDED_HOST=False, + ALLOWED_HOSTS=[ + 'forward.com', 'example.com', 'internal.com', '12.34.56.78', + '[2001:19f0:feee::dead:beef:cafe]', 'xn--4ca9at.com', + '.multitenant.com', 'INSENSITIVE.com', + ]) def test_http_get_host(self): - old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST - try: - settings.USE_X_FORWARDED_HOST = False - - # Check if X_FORWARDED_HOST is provided. - request = HttpRequest() - request.META = { - u'HTTP_X_FORWARDED_HOST': u'forward.com', - u'HTTP_HOST': u'example.com', - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, - } - # X_FORWARDED_HOST is ignored. - self.assertEqual(request.get_host(), 'example.com') - - # Check if X_FORWARDED_HOST isn't provided. - request = HttpRequest() - request.META = { - u'HTTP_HOST': u'example.com', - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, - } - self.assertEqual(request.get_host(), 'example.com') + # Check if X_FORWARDED_HOST is provided. + request = HttpRequest() + request.META = { + 'HTTP_X_FORWARDED_HOST': 'forward.com', + 'HTTP_HOST': 'example.com', + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + # X_FORWARDED_HOST is ignored. + self.assertEqual(request.get_host(), 'example.com') + + # Check if X_FORWARDED_HOST isn't provided. + request = HttpRequest() + request.META = { + 'HTTP_HOST': 'example.com', + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + self.assertEqual(request.get_host(), 'example.com') + + # Check if HTTP_HOST isn't provided. + request = HttpRequest() + request.META = { + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + self.assertEqual(request.get_host(), 'internal.com') - # Check if HTTP_HOST isn't provided. + # Check if HTTP_HOST isn't provided, and we're on a nonstandard port + request = HttpRequest() + request.META = { + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 8042, + } + self.assertEqual(request.get_host(), 'internal.com:8042') + + # Poisoned host headers are rejected as suspicious + legit_hosts = [ + 'example.com', + 'example.com:80', + '12.34.56.78', + '12.34.56.78:443', + '[2001:19f0:feee::dead:beef:cafe]', + '[2001:19f0:feee::dead:beef:cafe]:8080', + 'xn--4ca9at.com', # Punnycode for öäü.com + 'anything.multitenant.com', + 'multitenant.com', + 'insensitive.com', + ] + + poisoned_hosts = [ + 'example.com@evil.tld', + 'example.com:dr.frankenstein@evil.tld', + 'example.com:dr.frankenstein@evil.tld:80', + 'example.com:80/badpath', + 'example.com: recovermypassword.com', + 'other.com', # not in ALLOWED_HOSTS + ] + + for host in legit_hosts: request = HttpRequest() request.META = { - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, + 'HTTP_HOST': host, } - self.assertEqual(request.get_host(), 'internal.com') + request.get_host() - # Check if HTTP_HOST isn't provided, and we're on a nonstandard port - request = HttpRequest() - request.META = { - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 8042, - } - self.assertEqual(request.get_host(), 'internal.com:8042') - - # Poisoned host headers are rejected as suspicious - legit_hosts = [ - 'example.com', - 'example.com:80', - '12.34.56.78', - '12.34.56.78:443', - '[2001:19f0:feee::dead:beef:cafe]', - '[2001:19f0:feee::dead:beef:cafe]:8080', - 'xn--4ca9at.com', # Punnycode for öäü.com - ] - - poisoned_hosts = [ - 'example.com@evil.tld', - 'example.com:dr.frankenstein@evil.tld', - 'example.com:dr.frankenstein@evil.tld:80', - 'example.com:80/badpath', - 'example.com: recovermypassword.com', - ] - - for host in legit_hosts: + for host in poisoned_hosts: + with self.assertRaises(SuspiciousOperation): request = HttpRequest() request.META = { 'HTTP_HOST': host, } request.get_host() - for host in poisoned_hosts: - with self.assertRaises(SuspiciousOperation): - request = HttpRequest() - request.META = { - 'HTTP_HOST': host, - } - request.get_host() - - finally: - settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST - + @override_settings(USE_X_FORWARDED_HOST=True, ALLOWED_HOSTS=['*']) def test_http_get_host_with_x_forwarded_host(self): - old_USE_X_FORWARDED_HOST = settings.USE_X_FORWARDED_HOST - try: - settings.USE_X_FORWARDED_HOST = True - - # Check if X_FORWARDED_HOST is provided. - request = HttpRequest() - request.META = { - u'HTTP_X_FORWARDED_HOST': u'forward.com', - u'HTTP_HOST': u'example.com', - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, - } - # X_FORWARDED_HOST is obeyed. - self.assertEqual(request.get_host(), 'forward.com') - - # Check if X_FORWARDED_HOST isn't provided. - request = HttpRequest() - request.META = { - u'HTTP_HOST': u'example.com', - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, - } - self.assertEqual(request.get_host(), 'example.com') + # Check if X_FORWARDED_HOST is provided. + request = HttpRequest() + request.META = { + 'HTTP_X_FORWARDED_HOST': 'forward.com', + 'HTTP_HOST': 'example.com', + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + # X_FORWARDED_HOST is obeyed. + self.assertEqual(request.get_host(), 'forward.com') + + # Check if X_FORWARDED_HOST isn't provided. + request = HttpRequest() + request.META = { + 'HTTP_HOST': 'example.com', + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + self.assertEqual(request.get_host(), 'example.com') + + # Check if HTTP_HOST isn't provided. + request = HttpRequest() + request.META = { + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 80, + } + self.assertEqual(request.get_host(), 'internal.com') - # Check if HTTP_HOST isn't provided. + # Check if HTTP_HOST isn't provided, and we're on a nonstandard port + request = HttpRequest() + request.META = { + 'SERVER_NAME': 'internal.com', + 'SERVER_PORT': 8042, + } + self.assertEqual(request.get_host(), 'internal.com:8042') + + # Poisoned host headers are rejected as suspicious + legit_hosts = [ + 'example.com', + 'example.com:80', + '12.34.56.78', + '12.34.56.78:443', + '[2001:19f0:feee::dead:beef:cafe]', + '[2001:19f0:feee::dead:beef:cafe]:8080', + 'xn--4ca9at.com', # Punnycode for öäü.com + ] + + poisoned_hosts = [ + 'example.com@evil.tld', + 'example.com:dr.frankenstein@evil.tld', + 'example.com:dr.frankenstein@evil.tld:80', + 'example.com:80/badpath', + 'example.com: recovermypassword.com', + ] + + for host in legit_hosts: request = HttpRequest() request.META = { - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 80, + 'HTTP_HOST': host, } - self.assertEqual(request.get_host(), 'internal.com') + request.get_host() - # Check if HTTP_HOST isn't provided, and we're on a nonstandard port - request = HttpRequest() - request.META = { - u'SERVER_NAME': u'internal.com', - u'SERVER_PORT': 8042, - } - self.assertEqual(request.get_host(), 'internal.com:8042') - - # Poisoned host headers are rejected as suspicious - legit_hosts = [ - 'example.com', - 'example.com:80', - '12.34.56.78', - '12.34.56.78:443', - '[2001:19f0:feee::dead:beef:cafe]', - '[2001:19f0:feee::dead:beef:cafe]:8080', - 'xn--4ca9at.com', # Punnycode for öäü.com - ] - - poisoned_hosts = [ - 'example.com@evil.tld', - 'example.com:dr.frankenstein@evil.tld', - 'example.com:dr.frankenstein@evil.tld:80', - 'example.com:80/badpath', - 'example.com: recovermypassword.com', - ] - - for host in legit_hosts: + for host in poisoned_hosts: + with self.assertRaises(SuspiciousOperation): request = HttpRequest() request.META = { 'HTTP_HOST': host, } request.get_host() - for host in poisoned_hosts: - with self.assertRaises(SuspiciousOperation): - request = HttpRequest() - request.META = { - 'HTTP_HOST': host, - } - request.get_host() - - finally: - settings.USE_X_FORWARDED_HOST = old_USE_X_FORWARDED_HOST + @override_settings(DEBUG=True, ALLOWED_HOSTS=[]) + def test_host_validation_disabled_in_debug_mode(self): + """If ALLOWED_HOSTS is empty and DEBUG is True, all hosts pass.""" + request = HttpRequest() + request.META = { + 'HTTP_HOST': 'example.com', + } + self.assertEqual(request.get_host(), 'example.com') def test_near_expiration(self): "Cookie will expire when an near expiration time is provided" From 1c60d07ba23e0350351c278ad28d0bd5aa410b40 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Mon, 11 Feb 2013 21:54:53 -0700 Subject: [PATCH 187/367] [1.4.x] Restrict the XML deserializer to prevent network and entity-expansion DoS attacks. This is a security fix. Disclosure and advisory coming shortly. --- django/core/serializers/xml_serializer.py | 95 ++++++++++++++++++- .../serializers_regress/tests.py | 14 +++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/django/core/serializers/xml_serializer.py b/django/core/serializers/xml_serializer.py index a5edeac5af03..6360adabe38a 100644 --- a/django/core/serializers/xml_serializer.py +++ b/django/core/serializers/xml_serializer.py @@ -8,6 +8,8 @@ from django.utils.xmlutils import SimplerXMLGenerator from django.utils.encoding import smart_unicode from xml.dom import pulldom +from xml.sax import handler +from xml.sax.expatreader import ExpatParser as _ExpatParser class Serializer(base.Serializer): """ @@ -149,9 +151,13 @@ class Deserializer(base.Deserializer): def __init__(self, stream_or_string, **options): super(Deserializer, self).__init__(stream_or_string, **options) - self.event_stream = pulldom.parse(self.stream) + self.event_stream = pulldom.parse(self.stream, self._make_parser()) self.db = options.pop('using', DEFAULT_DB_ALIAS) + def _make_parser(self): + """Create a hardened XML parser (no custom/external entities).""" + return DefusedExpatParser() + def next(self): for event, node in self.event_stream: if event == "START_ELEMENT" and node.nodeName == "object": @@ -290,3 +296,90 @@ def getInnerText(node): else: pass return u"".join(inner_text) + + +# Below code based on Christian Heimes' defusedxml + + +class DefusedExpatParser(_ExpatParser): + """ + An expat parser hardened against XML bomb attacks. + + Forbids DTDs, external entity references + + """ + def __init__(self, *args, **kwargs): + _ExpatParser.__init__(self, *args, **kwargs) + self.setFeature(handler.feature_external_ges, False) + self.setFeature(handler.feature_external_pes, False) + + def start_doctype_decl(self, name, sysid, pubid, has_internal_subset): + raise DTDForbidden(name, sysid, pubid) + + def entity_decl(self, name, is_parameter_entity, value, base, + sysid, pubid, notation_name): + raise EntitiesForbidden(name, value, base, sysid, pubid, notation_name) + + def unparsed_entity_decl(self, name, base, sysid, pubid, notation_name): + # expat 1.2 + raise EntitiesForbidden(name, None, base, sysid, pubid, notation_name) + + def external_entity_ref_handler(self, context, base, sysid, pubid): + raise ExternalReferenceForbidden(context, base, sysid, pubid) + + def reset(self): + _ExpatParser.reset(self) + parser = self._parser + parser.StartDoctypeDeclHandler = self.start_doctype_decl + parser.EntityDeclHandler = self.entity_decl + parser.UnparsedEntityDeclHandler = self.unparsed_entity_decl + parser.ExternalEntityRefHandler = self.external_entity_ref_handler + + +class DefusedXmlException(ValueError): + """Base exception.""" + def __repr__(self): + return str(self) + + +class DTDForbidden(DefusedXmlException): + """Document type definition is forbidden.""" + def __init__(self, name, sysid, pubid): + super(DTDForbidden, self).__init__() + self.name = name + self.sysid = sysid + self.pubid = pubid + + def __str__(self): + tpl = "DTDForbidden(name='{}', system_id={!r}, public_id={!r})" + return tpl.format(self.name, self.sysid, self.pubid) + + +class EntitiesForbidden(DefusedXmlException): + """Entity definition is forbidden.""" + def __init__(self, name, value, base, sysid, pubid, notation_name): + super(EntitiesForbidden, self).__init__() + self.name = name + self.value = value + self.base = base + self.sysid = sysid + self.pubid = pubid + self.notation_name = notation_name + + def __str__(self): + tpl = "EntitiesForbidden(name='{}', system_id={!r}, public_id={!r})" + return tpl.format(self.name, self.sysid, self.pubid) + + +class ExternalReferenceForbidden(DefusedXmlException): + """Resolving an external reference is forbidden.""" + def __init__(self, context, base, sysid, pubid): + super(ExternalReferenceForbidden, self).__init__() + self.context = context + self.base = base + self.sysid = sysid + self.pubid = pubid + + def __str__(self): + tpl = "ExternalReferenceForbidden(system_id='{}', public_id={})" + return tpl.format(self.sysid, self.pubid) diff --git a/tests/regressiontests/serializers_regress/tests.py b/tests/regressiontests/serializers_regress/tests.py index 65194da42813..ac1d9dadf122 100644 --- a/tests/regressiontests/serializers_regress/tests.py +++ b/tests/regressiontests/serializers_regress/tests.py @@ -16,6 +16,7 @@ from cStringIO import StringIO except ImportError: from StringIO import StringIO +from django.core.serializers.xml_serializer import DTDForbidden try: import yaml @@ -523,3 +524,16 @@ def streamTest(format, self): if format != 'python': setattr(SerializerTests, 'test_' + format + '_serializer_stream', curry(streamTest, format)) + +class XmlDeserializerSecurityTests(TestCase): + + def test_no_dtd(self): + """ + The XML deserializer shouldn't allow a DTD. + + This is the most straightforward way to prevent all entity definitions + and avoid both external entities and entity-expansion attacks. + + """ + xml = '' + self.assertRaises(DTDForbidden, serializers.deserialize('xml', xml).next) From 0e7861aec73702f7933ce2a93056f7983939f0d6 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Mon, 4 Feb 2013 16:57:59 -0700 Subject: [PATCH 188/367] [1.4.x] Checked object permissions on admin history view. This is a security fix. Disclosure and advisory coming shortly. Patch by Russell Keith-Magee. --- django/contrib/admin/options.py | 10 ++++-- tests/regressiontests/admin_views/tests.py | 40 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 2071792bdbf1..78a08cd12048 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1317,15 +1317,21 @@ def delete_view(self, request, object_id, extra_context=None): def history_view(self, request, object_id, extra_context=None): "The 'history' admin view for this model." from django.contrib.admin.models import LogEntry + # First check if the user can see this history. model = self.model + obj = get_object_or_404(model, pk=unquote(object_id)) + + if not self.has_change_permission(request, obj): + raise PermissionDenied + + # Then get the history for this object. opts = model._meta app_label = opts.app_label action_list = LogEntry.objects.filter( object_id = object_id, content_type__id__exact = ContentType.objects.get_for_model(model).id ).select_related().order_by('action_time') - # If no history was found, see whether this object even exists. - obj = get_object_or_404(model, pk=unquote(object_id)) + context = { 'title': _('Change history: %s') % force_unicode(obj), 'action_list': action_list, diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index 8835e816dc3b..5531cb4e9611 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -1064,6 +1064,46 @@ def testChangeView(self): self.assertContains(request, 'login-form') self.client.get('/test_admin/admin/logout/') + def testHistoryView(self): + """History view should restrict access.""" + + # add user shoud not be able to view the list of article or change any of them + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.adduser_login) + response = self.client.get('/test_admin/admin/admin_views/article/1/history/') + self.assertEqual(response.status_code, 403) + self.client.get('/test_admin/admin/logout/') + + # change user can view all items and edit them + self.client.get('/test_admin/admin/') + self.client.post('/test_admin/admin/', self.changeuser_login) + response = self.client.get('/test_admin/admin/admin_views/article/1/history/') + self.assertEqual(response.status_code, 200) + + # Test redirection when using row-level change permissions. Refs #11513. + RowLevelChangePermissionModel.objects.create(id=1, name="odd id") + RowLevelChangePermissionModel.objects.create(id=2, name="even id") + for login_dict in [self.super_login, self.changeuser_login, self.adduser_login, self.deleteuser_login]: + self.client.post('/test_admin/admin/', login_dict) + response = self.client.get('/test_admin/admin/admin_views/rowlevelchangepermissionmodel/1/history/') + self.assertEqual(response.status_code, 403) + + response = self.client.get('/test_admin/admin/admin_views/rowlevelchangepermissionmodel/2/history/') + self.assertEqual(response.status_code, 200) + + self.client.get('/test_admin/admin/logout/') + + for login_dict in [self.joepublic_login, self.no_username_login]: + self.client.post('/test_admin/admin/', login_dict) + response = self.client.get('/test_admin/admin/admin_views/rowlevelchangepermissionmodel/1/history/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'login-form') + response = self.client.get('/test_admin/admin/admin_views/rowlevelchangepermissionmodel/2/history/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'login-form') + + self.client.get('/test_admin/admin/logout/') + def testConditionallyShowAddSectionLink(self): """ The foreign key widget should only show the "add related" button if the From 0cc350a896f70ace18280410eb616a9197d862b0 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Tue, 12 Feb 2013 11:22:41 +0100 Subject: [PATCH 189/367] [1.4.x] Added a default limit to the maximum number of forms in a formset. This is a security fix. Disclosure and advisory coming shortly. --- django/forms/formsets.py | 12 +++- docs/topics/forms/formsets.txt | 6 +- docs/topics/forms/modelforms.txt | 4 +- tests/regressiontests/forms/tests/formsets.py | 70 +++++++++++++++++-- .../generic_inline_admin/tests.py | 3 +- 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/django/forms/formsets.py b/django/forms/formsets.py index dcd2f017e77a..7feeeb1a13d9 100644 --- a/django/forms/formsets.py +++ b/django/forms/formsets.py @@ -19,6 +19,9 @@ ORDERING_FIELD_NAME = 'ORDER' DELETION_FIELD_NAME = 'DELETE' +# default maximum number of forms in a formset, to prevent memory exhaustion +DEFAULT_MAX_NUM = 1000 + class ManagementForm(Form): """ ``ManagementForm`` is used to keep track of how many form instances @@ -111,7 +114,7 @@ def initial_form_count(self): def _construct_forms(self): # instantiate all the forms and put them in self.forms self.forms = [] - for i in xrange(self.total_form_count()): + for i in xrange(min(self.total_form_count(), self.absolute_max)): self.forms.append(self._construct_form(i)) def _construct_form(self, i, **kwargs): @@ -360,9 +363,14 @@ def as_ul(self): def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None): """Return a FormSet for the given form class.""" + if max_num is None: + max_num = DEFAULT_MAX_NUM + # hard limit on forms instantiated, to prevent memory-exhaustion attacks + # limit defaults to DEFAULT_MAX_NUM, but developer can increase it via max_num + absolute_max = max(DEFAULT_MAX_NUM, max_num) attrs = {'form': form, 'extra': extra, 'can_order': can_order, 'can_delete': can_delete, - 'max_num': max_num} + 'max_num': max_num, 'absolute_max': absolute_max} return type(form.__name__ + 'FormSet', (formset,), attrs) def all_valid(formsets): diff --git a/docs/topics/forms/formsets.txt b/docs/topics/forms/formsets.txt index b524c24ad2c4..03fa317c1e4d 100644 --- a/docs/topics/forms/formsets.txt +++ b/docs/topics/forms/formsets.txt @@ -108,8 +108,10 @@ If the value of ``max_num`` is greater than the number of existing objects, up to ``extra`` additional blank forms will be added to the formset, so long as the total number of forms does not exceed ``max_num``. -A ``max_num`` value of ``None`` (the default) puts no limit on the number of -forms displayed. Please note that the default value of ``max_num`` was changed +A ``max_num`` value of ``None`` (the default) puts a high limit on the number +of forms displayed (1000). In practice this is equivalent to no limit. + +Please note that the default value of ``max_num`` was changed from ``0`` to ``None`` in version 1.2 to allow ``0`` as a valid value. Formset validation diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index 5ca33188721e..41953e16ea64 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -703,8 +703,8 @@ so long as the total number of forms does not exceed ``max_num``:: .. versionchanged:: 1.2 -A ``max_num`` value of ``None`` (the default) puts no limit on the number of -forms displayed. +A ``max_num`` value of ``None`` (the default) puts a high limit on the number +of forms displayed (1000). In practice this is equivalent to no limit. Using a model formset in a view ------------------------------- diff --git a/tests/regressiontests/forms/tests/formsets.py b/tests/regressiontests/forms/tests/formsets.py index 05ef978c4504..7c69e7e20ae0 100644 --- a/tests/regressiontests/forms/tests/formsets.py +++ b/tests/regressiontests/forms/tests/formsets.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from django.forms import Form, CharField, IntegerField, ValidationError, DateField +from django.forms import Form, CharField, IntegerField, ValidationError, DateField, formsets from django.forms.formsets import formset_factory, BaseFormSet from django.test import TestCase @@ -47,7 +47,7 @@ def test_basic_formset(self): # for adding data. By default, it displays 1 blank form. It can display more, # but we'll look at how to do so later. formset = ChoiceFormSet(auto_id=False, prefix='choices') - self.assertHTMLEqual(str(formset), """ + self.assertHTMLEqual(str(formset), """ Choice: Votes:""") @@ -650,8 +650,8 @@ def test_limiting_max_forms(self): # Limiting the maximum number of forms ######################################## # Base case for max_num. - # When not passed, max_num will take its default value of None, i.e. unlimited - # number of forms, only controlled by the value of the extra parameter. + # When not passed, max_num will take a high default value, leaving the + # number of forms only controlled by the value of the extra parameter. LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=3) formset = LimitedFavoriteDrinkFormSet() @@ -698,8 +698,8 @@ def test_limiting_max_forms(self): def test_max_num_with_initial_data(self): # max_num with initial data - # When not passed, max_num will take its default value of None, i.e. unlimited - # number of forms, only controlled by the values of the initial and extra + # When not passed, max_num will take a high default value, leaving the + # number of forms only controlled by the value of the initial and extra # parameters. initial = [ @@ -844,6 +844,64 @@ def test_formset_nonzero(self): self.assertEqual(len(formset.forms), 0) self.assertTrue(formset) + def test_hard_limit_on_instantiated_forms(self): + """A formset has a hard limit on the number of forms instantiated.""" + # reduce the default limit of 1000 temporarily for testing + _old_DEFAULT_MAX_NUM = formsets.DEFAULT_MAX_NUM + try: + formsets.DEFAULT_MAX_NUM = 3 + ChoiceFormSet = formset_factory(Choice) + # someone fiddles with the mgmt form data... + formset = ChoiceFormSet( + { + 'choices-TOTAL_FORMS': '4', + 'choices-INITIAL_FORMS': '0', + 'choices-MAX_NUM_FORMS': '4', + 'choices-0-choice': 'Zero', + 'choices-0-votes': '0', + 'choices-1-choice': 'One', + 'choices-1-votes': '1', + 'choices-2-choice': 'Two', + 'choices-2-votes': '2', + 'choices-3-choice': 'Three', + 'choices-3-votes': '3', + }, + prefix='choices', + ) + # But we still only instantiate 3 forms + self.assertEqual(len(formset.forms), 3) + finally: + formsets.DEFAULT_MAX_NUM = _old_DEFAULT_MAX_NUM + + def test_increase_hard_limit(self): + """Can increase the built-in forms limit via a higher max_num.""" + # reduce the default limit of 1000 temporarily for testing + _old_DEFAULT_MAX_NUM = formsets.DEFAULT_MAX_NUM + try: + formsets.DEFAULT_MAX_NUM = 3 + # for this form, we want a limit of 4 + ChoiceFormSet = formset_factory(Choice, max_num=4) + formset = ChoiceFormSet( + { + 'choices-TOTAL_FORMS': '4', + 'choices-INITIAL_FORMS': '0', + 'choices-MAX_NUM_FORMS': '4', + 'choices-0-choice': 'Zero', + 'choices-0-votes': '0', + 'choices-1-choice': 'One', + 'choices-1-votes': '1', + 'choices-2-choice': 'Two', + 'choices-2-votes': '2', + 'choices-3-choice': 'Three', + 'choices-3-votes': '3', + }, + prefix='choices', + ) + # This time four forms are instantiated + self.assertEqual(len(formset.forms), 4) + finally: + formsets.DEFAULT_MAX_NUM = _old_DEFAULT_MAX_NUM + data = { 'choices-TOTAL_FORMS': '1', # the number of forms rendered diff --git a/tests/regressiontests/generic_inline_admin/tests.py b/tests/regressiontests/generic_inline_admin/tests.py index db81eec47aa2..237e396dd2ba 100644 --- a/tests/regressiontests/generic_inline_admin/tests.py +++ b/tests/regressiontests/generic_inline_admin/tests.py @@ -7,6 +7,7 @@ from django.contrib.admin.sites import AdminSite from django.contrib.contenttypes.generic import ( generic_inlineformset_factory, GenericTabularInline) +from django.forms.formsets import DEFAULT_MAX_NUM from django.forms.models import ModelForm from django.test import TestCase @@ -241,7 +242,7 @@ def test_get_formset_kwargs(self): # Create a formset with default arguments formset = media_inline.get_formset(request) - self.assertEqual(formset.max_num, None) + self.assertEqual(formset.max_num, DEFAULT_MAX_NUM) self.assertEqual(formset.can_order, False) # Create a formset with custom keyword arguments From 62d5338bf208aea3e10b020d0cf65bd93dcc253f Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 12 Feb 2013 15:33:38 -0700 Subject: [PATCH 190/367] [1.4.x] Update 1.4.4 release notes for all security fixes. --- docs/releases/1.4.4.txt | 52 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/releases/1.4.4.txt b/docs/releases/1.4.4.txt index 3c5513bb8f16..cdbf159b79e7 100644 --- a/docs/releases/1.4.4.txt +++ b/docs/releases/1.4.4.txt @@ -4,8 +4,13 @@ Django 1.4.4 release notes *February 19, 2013* +Django 1.4.4 fixes four security issues present in previous Django releases in +the 1.4 series, as well as several other bugs and numerous documentation +improvements. + This is the fourth bugfix/security release in the Django 1.4 series. + Host header poisoning --------------------- @@ -24,16 +29,61 @@ Host header not matching an entry in this list will raise ``SuspiciousOperation`` if ``request.get_host()`` is called. For full details see the documentation for the :setting:`ALLOWED_HOSTS` setting. -The default value for this setting in Django 1.4.4 is `['*']` (matching any +The default value for this setting in Django 1.4.4 is ``['*']`` (matching any host), for backwards-compatibility, but we strongly encourage all sites to set a more restrictive value. This host validation is disabled when ``DEBUG`` is ``True`` or when running tests. +XML deserialization +------------------- + +The XML parser in the Python standard library is vulnerable to a number of +denial-of-service attacks via external entities and entity expansion. Django +uses this parser for deserializing XML-formatted database fixtures. This +deserializer is not intended for use with untrusted data, but in order to err +on the side of safety in Django 1.4.4 the XML deserializer refuses to parse an +XML document with a DTD (DOCTYPE definition), which closes off these attack +avenues. + +These issues in the Python standard library are CVE-2013-1664 and +CVE-2013-1665. More information available `from the Python security team`_. + +Django's XML serializer does not create documents with a DTD, so this should +not cause any issues with the typical round-trip from ``dumpdata`` to +``loaddata``, but if you feed your own XML documents to the ``loaddata`` +management command, you will need to ensure they do not contain a DTD. + +.. _from the Python security team: http://blog.python.org/2013/02/announcing-defusedxml-fixes-for-xml.html + + +Formset memory exhaustion +------------------------- + +Previous versions of Django did not validate or limit the form-count data +provided by the client in a formset's management form, making it possible to +exhaust a server's available memory by forcing it to create very large numbers +of forms. + +In Django 1.4.4, all formsets have a strictly-enforced maximum number of forms +(1000 by default, though it can be set higher via the ``max_num`` formset +factory argument). + + +Admin history view information leakage +-------------------------------------- + +In previous versions of Django, an admin user without change permission on a +model could still view the unicode representation of instances via their admin +history log. Django 1.4.4 now limits the admin history log view for an object +to users with change permission for that model. + + Other bugfixes and changes ========================== +* Prevented transaction state from leaking from one request to the next (#19707). * Changed a SQL command syntax to be MySQL 4 compatible (#19702). * Added backwards-compatibility with old unsalted MD5 passwords (#18144). * Numerous documentation improvements and fixes. From f61f800c29e2b421a00b52c51ec513eee944d5d8 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Tue, 19 Feb 2013 14:17:23 -0600 Subject: [PATCH 191/367] [1.4.x] Bump version numbers for security release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 2c2cfb05b7df..853afc2df4fe 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 3, 'final', 0) +VERSION = (1, 4, 4, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 45e2b502d47b..640364426ebc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.3' +version = '1.4.4' # The full version, including alpha/beta/rc tags. -release = '1.4.3' +release = '1.4.4' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index e9ffb0c53738..070565f32539 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.3.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.4.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 5d1791ffd2ffdb70cbcf81a49cb0a7cda3fe1f46 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 19 Feb 2013 18:22:22 -0700 Subject: [PATCH 192/367] [1.4.x] Don't characterize XML vulnerabilities as DoS-only. --- docs/releases/1.4.4.txt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/releases/1.4.4.txt b/docs/releases/1.4.4.txt index cdbf159b79e7..c5fcbc3e39ce 100644 --- a/docs/releases/1.4.4.txt +++ b/docs/releases/1.4.4.txt @@ -40,12 +40,11 @@ XML deserialization ------------------- The XML parser in the Python standard library is vulnerable to a number of -denial-of-service attacks via external entities and entity expansion. Django -uses this parser for deserializing XML-formatted database fixtures. This -deserializer is not intended for use with untrusted data, but in order to err -on the side of safety in Django 1.4.4 the XML deserializer refuses to parse an -XML document with a DTD (DOCTYPE definition), which closes off these attack -avenues. +attacks via external entities and entity expansion. Django uses this parser for +deserializing XML-formatted database fixtures. This deserializer is not +intended for use with untrusted data, but in order to err on the side of safety +in Django 1.4.4 the XML deserializer refuses to parse an XML document with a +DTD (DOCTYPE definition), which closes off these attack avenues. These issues in the Python standard library are CVE-2013-1664 and CVE-2013-1665. More information available `from the Python security team`_. From 4cdfb24c9847f89a332742dbc476f189de4989dc Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 19 Feb 2013 18:36:44 -0700 Subject: [PATCH 193/367] [1.4.x] Fixed #19857 -- Fixed broken docs link in project template. --- django/conf/project_template/project_name/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/conf/project_template/project_name/settings.py b/django/conf/project_template/project_name/settings.py index 5780aca0124e..d74eccf7ec3f 100644 --- a/django/conf/project_template/project_name/settings.py +++ b/django/conf/project_template/project_name/settings.py @@ -21,7 +21,7 @@ } # Hosts/domain names that are valid for this site; required if DEBUG is False -# See https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#allowed-hosts +# See https://docs.djangoproject.com/en/1.4/ref/settings/#allowed-hosts ALLOWED_HOSTS = [] # Local time zone for this installation. Choices can be found here: From 3adfc3f97dc8ac5985a495b1a690b964f48ba208 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Wed, 20 Feb 2013 12:26:54 -0700 Subject: [PATCH 194/367] [1.4.x] Note that ALLOWED_HOSTS default changes in Django 1.5. --- docs/ref/settings.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index f992eef3e7f4..43aa9b2905ef 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -104,6 +104,11 @@ This validation only applies via :meth:`~django.http.HttpRequest.get_host()`; if your code accesses the ``Host`` header directly from ``request.META`` you are bypassing this security protection. +The default value of this setting in Django 1.4.4+ is ``['*']`` (accept any +host) in order to avoid breaking backwards-compatibility in a security update, +but in Django 1.5+ the default is ``[]`` and explicitly configuring this +setting is required. + .. setting:: ALLOWED_INCLUDE_ROOTS ALLOWED_INCLUDE_ROOTS From 67a937c2c24b7eee8be13a1d3faff595d8f45839 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Wed, 20 Feb 2013 13:53:27 -0600 Subject: [PATCH 195/367] [1.4.x] Bump version numbers to roll a clean package. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 853afc2df4fe..08a40178cd35 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 4, 'final', 0) +VERSION = (1, 4, 5, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 640364426ebc..c81c2e38fa8b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.4' +version = '1.4.5' # The full version, including alpha/beta/rc tags. -release = '1.4.4' +release = '1.4.5' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index 070565f32539..3be1f5f87e19 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.4.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.5.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 3872bc51c966ac779f24772e24511423491ea49e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Wed, 20 Feb 2013 03:08:55 +0200 Subject: [PATCH 196/367] [1.4.x] Made a couple of selenium tests wait for page loaded The admin_widgets tests were issuing click() to the browser but didn't wait for the effects of those clicks. This caused the resulting request to be processed concurrently with the test case. When using in-memory SQLite this caused weird failures. Also added wait_page_loaded() to admin selenium tests for code reuse. Fixed #19856, cherry-pick of 50677b29af39ca670274fb45087415c883c78b04 --- django/contrib/admin/tests.py | 17 +++++++++++++++-- tests/regressiontests/admin_inlines/tests.py | 12 ++---------- tests/regressiontests/admin_views/tests.py | 11 +---------- tests/regressiontests/admin_widgets/tests.py | 7 +++++-- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py index 9d94127b3461..a66e89f06dec 100644 --- a/django/contrib/admin/tests.py +++ b/django/contrib/admin/tests.py @@ -49,6 +49,20 @@ def wait_loaded_tag(self, tag_name, timeout=10): timeout ) + def wait_page_loaded(self): + """ + Block until page has started to load. + """ + from selenium.common.exceptions import TimeoutException + try: + # Wait for the next page to be loaded + self.wait_loaded_tag('body') + except TimeoutException: + # IE7 occasionnally returns an error "Internet Explorer cannot + # display the webpage" and doesn't load the next page. We just + # ignore it. + pass + def admin_login(self, username, password, login_url='/admin/'): """ Helper function to log into the admin. @@ -61,8 +75,7 @@ def admin_login(self, username, password, login_url='/admin/'): login_text = _('Log in') self.selenium.find_element_by_xpath( '//input[@value="%s"]' % login_text).click() - # Wait for the next page to be loaded. - self.wait_loaded_tag('body') + self.wait_page_loaded() def get_css_value(self, selector, attribute): """ diff --git a/tests/regressiontests/admin_inlines/tests.py b/tests/regressiontests/admin_inlines/tests.py index 8b620cc57d6a..27c643145862 100644 --- a/tests/regressiontests/admin_inlines/tests.py +++ b/tests/regressiontests/admin_inlines/tests.py @@ -445,15 +445,7 @@ def test_add_inlines(self): self.selenium.find_element_by_name('profile_set-2-last_name').send_keys('2 last name 2') self.selenium.find_element_by_xpath('//input[@value="Save"]').click() - - try: - # Wait for the next page to be loaded. - self.wait_loaded_tag('body') - except TimeoutException: - # IE7 occasionnally returns an error "Internet Explorer cannot - # display the webpage" and doesn't load the next page. We just - # ignore it. - pass + self.wait_page_loaded() # Check that the objects have been created in the database self.assertEqual(ProfileCollection.objects.all().count(), 1) @@ -502,4 +494,4 @@ class SeleniumChromeTests(SeleniumFirefoxTests): webdriver_class = 'selenium.webdriver.chrome.webdriver.WebDriver' class SeleniumIETests(SeleniumFirefoxTests): - webdriver_class = 'selenium.webdriver.ie.webdriver.WebDriver' \ No newline at end of file + webdriver_class = 'selenium.webdriver.ie.webdriver.WebDriver' diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index 5531cb4e9611..b695453e17f7 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -3034,16 +3034,7 @@ def test_basic(self): # Save and check that everything is properly stored in the database self.selenium.find_element_by_xpath('//input[@value="Save"]').click() - - try: - # Wait for the next page to be loaded. - self.wait_loaded_tag('body') - except TimeoutException: - # IE7 occasionnally returns an error "Internet Explorer cannot - # display the webpage" and doesn't load the next page. We just - # ignore it. - pass - + self.wait_page_loaded() self.assertEqual(MainPrepopulated.objects.all().count(), 1) MainPrepopulated.objects.get( name=u' this is the mAin nÀMë and it\'s awεšome', diff --git a/tests/regressiontests/admin_widgets/tests.py b/tests/regressiontests/admin_widgets/tests.py index 87e0309ddcb8..974f4d100762 100644 --- a/tests/regressiontests/admin_widgets/tests.py +++ b/tests/regressiontests/admin_widgets/tests.py @@ -597,12 +597,14 @@ def test_basic(self): self.selenium.get( '%s%s' % (self.live_server_url, '/admin_widgets/school/%s/' % self.school.id)) + self.wait_page_loaded() self.execute_basic_operations('vertical', 'students') self.execute_basic_operations('horizontal', 'alumni') # Save and check that everything is properly stored in the database --- self.selenium.find_element_by_xpath('//input[@value="Save"]').click() - self.school = models.School.objects.get(id=self.school.id) # Reload from database + self.wait_page_loaded() + self.school = models.School.objects.get(id=self.school.id) # Reload from database self.assertEqual(list(self.school.students.all()), [self.arthur, self.cliff, self.jason, self.john]) self.assertEqual(list(self.school.alumni.all()), @@ -681,6 +683,7 @@ def test_filter(self): # Save and check that everything is properly stored in the database --- self.selenium.find_element_by_xpath('//input[@value="Save"]').click() + self.wait_page_loaded() self.school = models.School.objects.get(id=self.school.id) # Reload from database self.assertEqual(list(self.school.students.all()), [self.jason, self.peter]) @@ -691,4 +694,4 @@ class HorizontalVerticalFilterSeleniumChromeTests(HorizontalVerticalFilterSeleni webdriver_class = 'selenium.webdriver.chrome.webdriver.WebDriver' class HorizontalVerticalFilterSeleniumIETests(HorizontalVerticalFilterSeleniumFirefoxTests): - webdriver_class = 'selenium.webdriver.ie.webdriver.WebDriver' \ No newline at end of file + webdriver_class = 'selenium.webdriver.ie.webdriver.WebDriver' From 0f555f813b1bc3995d19160be17c8b2cb6a440c1 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Sat, 23 Feb 2013 19:25:00 -0800 Subject: [PATCH 197/367] [1.4.x] Fixed #19902 -- backport of as_view docs --- docs/ref/class-based-views.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ref/class-based-views.txt b/docs/ref/class-based-views.txt index 5223aee0180c..5869a1eda3b5 100644 --- a/docs/ref/class-based-views.txt +++ b/docs/ref/class-based-views.txt @@ -862,6 +862,12 @@ View one user visiting your view could have an effect on subsequent users visiting the same view. + .. classmethod:: as_view(**initkwargs) + + Returns a callable view that takes a request and returns a response:: + + response = MyView.as_view()(request) + .. method:: dispatch(request, *args, **kwargs) The ``view`` part of the view -- the method that accepts a ``request`` From db1e8bdc33a8bfa4b47a765cb2a7a66aafa52bad Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 19 Feb 2013 18:19:50 -0500 Subject: [PATCH 198/367] [1.4.x] Fixed #19728 - Updated API stability doc to reflect current meaning of "stable". Backport of 132d5822b0 from master. --- docs/misc/api-stability.txt | 104 ++++-------------------------------- 1 file changed, 10 insertions(+), 94 deletions(-) diff --git a/docs/misc/api-stability.txt b/docs/misc/api-stability.txt index 75fa6b44187a..e14e847335d0 100644 --- a/docs/misc/api-stability.txt +++ b/docs/misc/api-stability.txt @@ -4,17 +4,19 @@ API stability :doc:`The release of Django 1.0 ` comes with a promise of API stability and forwards-compatibility. In a nutshell, this means that code you -develop against Django 1.0 will continue to work against 1.1 unchanged, and you -should need to make only minor changes for any 1.X release. +develop against a 1.X version of Django will continue to work with future +1.X releases. You may need to make minor changes when upgrading the version of +Django your project uses: see the "Backwards incompatible changes" section of +the :doc:`release note ` for the version or versions to which +you are upgrading. What "stable" means =================== In this context, stable means: -- All the public APIs -- everything documented in the linked documents below, - and all methods that don't begin with an underscore -- will not be moved or - renamed without providing backwards-compatible aliases. +- All the public APIs (everything in this documentation) will not be moved + or renamed without providing backwards-compatible aliases. - If new features are added to these APIs -- which is quite possible -- they will not break or change the meaning of existing methods. In other @@ -35,77 +37,7 @@ Stable APIs =========== In general, everything covered in the documentation -- with the exception of -anything in the :doc:`internals area ` is considered stable as -of 1.0. This includes these APIs: - -- :doc:`Authorization ` - -- :doc:`Caching `. - -- :doc:`Model definition, managers, querying and transactions - ` - -- :doc:`Sending email `. - -- :doc:`File handling and storage ` - -- :doc:`Forms ` - -- :doc:`HTTP request/response handling `, including file - uploads, middleware, sessions, URL resolution, view, and shortcut APIs. - -- :doc:`Generic views `. - -- :doc:`Internationalization `. - -- :doc:`Pagination ` - -- :doc:`Serialization ` - -- :doc:`Signals ` - -- :doc:`Templates `, including the language, Python-level - :doc:`template APIs `, and :doc:`custom template tags - and libraries `. We may add new template - tags in the future and the names may inadvertently clash with - external template tags. Before adding any such tags, we'll ensure that - Django raises an error if it tries to load tags with duplicate names. - -- :doc:`Testing ` - -- :doc:`django-admin utility `. - -- :doc:`Built-in middleware ` - -- :doc:`Request/response objects `. - -- :doc:`Settings `. Note, though that while the :doc:`list of - built-in settings ` can be considered complete we may -- and - probably will -- add new settings in future versions. This is one of those - places where "'stable' does not mean 'complete.'" - -- :doc:`Built-in signals `. Like settings, we'll probably add - new signals in the future, but the existing ones won't break. - -- :doc:`Unicode handling `. - -- Everything covered by the :doc:`HOWTO guides `. - -``django.utils`` ----------------- - -Most of the modules in ``django.utils`` are designed for internal use. Only -the following parts of :doc:`django.utils ` can be considered stable: - -- ``django.utils.cache`` -- ``django.utils.datastructures.SortedDict`` -- only this single class; the - rest of the module is for internal use. -- ``django.utils.encoding`` -- ``django.utils.feedgenerator`` -- ``django.utils.http`` -- ``django.utils.safestring`` -- ``django.utils.translation`` -- ``django.utils.tzinfo`` +anything in the :doc:`internals area ` is considered stable. Exceptions ========== @@ -118,24 +50,8 @@ Security fixes If we become aware of a security problem -- hopefully by someone following our :ref:`security reporting policy ` -- we'll do -everything necessary to fix it. This might mean breaking backwards compatibility; security trumps the compatibility guarantee. - -Contributed applications (``django.contrib``) ---------------------------------------------- - -While we'll make every effort to keep these APIs stable -- and have no plans to -break any contrib apps -- this is an area that will have more flux between -releases. As the Web evolves, Django must evolve with it. - -However, any changes to contrib apps will come with an important guarantee: -we'll make sure it's always possible to use an older version of a contrib app if -we need to make changes. Thus, if Django 1.5 ships with a backwards-incompatible -``django.contrib.flatpages``, we'll make sure you can still use the Django 1.4 -version alongside Django 1.5. This will continue to allow for easy upgrades. - -Historically, apps in ``django.contrib`` have been more stable than the core, so -in practice we probably won't have to ever make this exception. However, it's -worth noting if you're building apps that depend on ``django.contrib``. +everything necessary to fix it. This might mean breaking backwards +compatibility; security trumps the compatibility guarantee. APIs marked as internal ----------------------- From 52bac4ede1c825c097c7a7027c1637a7cd9cbf4d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 25 Feb 2013 13:01:15 -0500 Subject: [PATCH 199/367] [1.4.x] Fixed #19911 - Updated generic view links. Thanks marc@ for the report. --- docs/intro/tutorial04.txt | 2 +- docs/ref/contrib/messages.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/intro/tutorial04.txt b/docs/intro/tutorial04.txt index 85d54c34f6ae..6a55917fd591 100644 --- a/docs/intro/tutorial04.txt +++ b/docs/intro/tutorial04.txt @@ -320,7 +320,7 @@ function anymore -- generic views can be (and are) used multiple times Run the server, and use your new polling app based on generic views. For full details on generic views, see the :doc:`generic views documentation -`. +`. Coming soon =========== diff --git a/docs/ref/contrib/messages.txt b/docs/ref/contrib/messages.txt index 322764591546..504691bbbde6 100644 --- a/docs/ref/contrib/messages.txt +++ b/docs/ref/contrib/messages.txt @@ -275,7 +275,7 @@ example:: messages.info(request, 'Hello world.', fail_silently=True) Internally, Django uses this functionality in the create, update, and delete -:doc:`generic views ` so that they work even if the +:doc:`generic views ` so that they work even if the message framework is disabled. .. note:: From 97a67b26f3debc40c73f835dd17cbef98fe5d8c6 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Mon, 25 Feb 2013 20:01:57 +0100 Subject: [PATCH 200/367] [1.4.x] Fixed #18144 -- Restored compatibility with SHA1 hashes with empty salt. Thanks dahool for the report and initial version of the patch. Backport of 633d8de from master. --- django/conf/global_settings.py | 1 + django/contrib/auth/hashers.py | 52 ++++++++++++++++++++++++---- django/contrib/auth/tests/hashers.py | 24 +++++++++---- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 026e39b7c94d..6512e4e3cbab 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -516,6 +516,7 @@ 'django.contrib.auth.hashers.BCryptPasswordHasher', 'django.contrib.auth.hashers.SHA1PasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher', + 'django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher', 'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', 'django.contrib.auth.hashers.CryptPasswordHasher', ) diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index 1a93e8945bbf..a9dbcc9568e1 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -35,9 +35,14 @@ def check_password(password, encoded, setter=None, preferred='default'): password = smart_str(password) encoded = smart_str(encoded) + # Ancient versions of Django created plain MD5 passwords and accepted + # MD5 passwords with an empty salt. if ((len(encoded) == 32 and '$' not in encoded) or (len(encoded) == 37 and encoded.startswith('md5$$'))): hasher = get_hasher('unsalted_md5') + # Ancient versions of Django accepted SHA1 passwords with an empty salt. + elif len(encoded) == 46 and encoded.startswith('sha1$$'): + hasher = get_hasher('unsalted_sha1') else: algorithm = encoded.split('$', 1)[0] hasher = get_hasher(algorithm) @@ -330,14 +335,48 @@ def safe_summary(self, encoded): ]) -class UnsaltedMD5PasswordHasher(BasePasswordHasher): +class UnsaltedSHA1PasswordHasher(BasePasswordHasher): + """ + Very insecure algorithm that you should *never* use; stores SHA1 hashes + with an empty salt. + + This class is implemented because Django used to accept such password + hashes. Some older Django installs still have these values lingering + around so we need to handle and upgrade them properly. """ - I am an incredibly insecure algorithm you should *never* use; - stores unsalted MD5 hashes without the algorithm prefix. + algorithm = "unsalted_sha1" + + def salt(self): + return '' - This class is implemented because Django used to store passwords - this way. Some older Django installs still have these values - lingering around so we need to handle and upgrade them properly. + def encode(self, password, salt): + assert salt == '' + hash = hashlib.sha1(password).hexdigest() + return 'sha1$$%s' % hash + + def verify(self, password, encoded): + encoded_2 = self.encode(password, '') + return constant_time_compare(encoded, encoded_2) + + def safe_summary(self, encoded): + assert encoded.startswith('sha1$$') + hash = encoded[6:] + return SortedDict([ + (_('algorithm'), self.algorithm), + (_('hash'), mask_hash(hash)), + ]) + + +class UnsaltedMD5PasswordHasher(BasePasswordHasher): + """ + Incredibly insecure algorithm that you should *never* use; stores unsalted + MD5 hashes without the algorithm prefix, also accepts MD5 hashes with an + empty salt. + + This class is implemented because Django used to store passwords this way + and to accept such password hashes. Some older Django installs still have + these values lingering around so we need to handle and upgrade them + properly. """ algorithm = "unsalted_md5" @@ -345,6 +384,7 @@ def salt(self): return '' def encode(self, password, salt): + assert salt == '' return hashlib.md5(password).hexdigest() def verify(self, password, encoded): diff --git a/django/contrib/auth/tests/hashers.py b/django/contrib/auth/tests/hashers.py index 6203e9a89956..bf68c45a1b32 100644 --- a/django/contrib/auth/tests/hashers.py +++ b/django/contrib/auth/tests/hashers.py @@ -1,5 +1,5 @@ from django.conf.global_settings import PASSWORD_HASHERS as default_hashers -from django.contrib.auth.hashers import (is_password_usable, +from django.contrib.auth.hashers import (is_password_usable, check_password, make_password, PBKDF2PasswordHasher, load_hashers, PBKDF2SHA1PasswordHasher, get_hasher, UNUSABLE_PASSWORD) from django.utils import unittest @@ -31,7 +31,7 @@ def test_simple(self): def test_pkbdf2(self): encoded = make_password('letmein', 'seasalt', 'pbkdf2_sha256') - self.assertEqual(encoded, + self.assertEqual(encoded, 'pbkdf2_sha256$10000$seasalt$FQCNpiZpTb0zub+HBsH6TOwyRxJ19FwvjbweatNmK/Y=') self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) @@ -39,7 +39,7 @@ def test_pkbdf2(self): def test_sha1(self): encoded = make_password('letmein', 'seasalt', 'sha1') - self.assertEqual(encoded, + self.assertEqual(encoded, 'sha1$seasalt$fec3530984afba6bade3347b7140d1a7da7da8c7') self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) @@ -47,14 +47,14 @@ def test_sha1(self): def test_md5(self): encoded = make_password('letmein', 'seasalt', 'md5') - self.assertEqual(encoded, + self.assertEqual(encoded, 'md5$seasalt$f5531bef9f3687d0ccf0f617f0e25573') self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) def test_unsalted_md5(self): - encoded = make_password('letmein', 'seasalt', 'unsalted_md5') + encoded = make_password('letmein', '', 'unsalted_md5') self.assertEqual(encoded, '0d107d09f5bbe40cade3de5c71e9e9b7') self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) @@ -65,6 +65,16 @@ def test_unsalted_md5(self): self.assertTrue(check_password(u'letmein', alt_encoded)) self.assertFalse(check_password('letmeinz', alt_encoded)) + def test_unsalted_sha1(self): + encoded = make_password('letmein', '', 'unsalted_sha1') + self.assertEqual(encoded, 'sha1$$b7a875fc1ea228b9061041b7cec4bd3c52ab3ce3') + self.assertTrue(is_password_usable(encoded)) + self.assertTrue(check_password('letmein', encoded)) + self.assertFalse(check_password('letmeinz', encoded)) + # Raw SHA1 isn't acceptable + alt_encoded = encoded[6:] + self.assertRaises(ValueError, check_password, 'letmein', alt_encoded) + @skipUnless(crypt, "no crypt module to generate password.") def test_crypt(self): encoded = make_password('letmein', 'ab', 'crypt') @@ -98,14 +108,14 @@ def doit(): def test_low_level_pkbdf2(self): hasher = PBKDF2PasswordHasher() encoded = hasher.encode('letmein', 'seasalt') - self.assertEqual(encoded, + self.assertEqual(encoded, 'pbkdf2_sha256$10000$seasalt$FQCNpiZpTb0zub+HBsH6TOwyRxJ19FwvjbweatNmK/Y=') self.assertTrue(hasher.verify('letmein', encoded)) def test_low_level_pbkdf2_sha1(self): hasher = PBKDF2SHA1PasswordHasher() encoded = hasher.encode('letmein', 'seasalt') - self.assertEqual(encoded, + self.assertEqual(encoded, 'pbkdf2_sha1$10000$seasalt$91JiNKgwADC8j2j86Ije/cc4vfQ=') self.assertTrue(hasher.verify('letmein', encoded)) From 577a27a9fc921b29c08b379116633e7b0e5a7f6b Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Wed, 27 Feb 2013 21:28:55 +0100 Subject: [PATCH 201/367] [1.4.x] Fixed #19926 -- Fixed a link to code example in queries docs Thanks Randy Salvo for the report. --- docs/topics/db/queries.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/db/queries.txt b/docs/topics/db/queries.txt index 2e14abe85038..ed38d1dbc863 100644 --- a/docs/topics/db/queries.txt +++ b/docs/topics/db/queries.txt @@ -815,7 +815,7 @@ precede the definition of any keyword arguments. For example:: The `OR lookups examples`_ in the Django unit tests show some possible uses of ``Q``. - .. _OR lookups examples: https://code.djangoproject.com/browser/django/trunk/tests/modeltests/or_lookups/tests.py + .. _OR lookups examples: https://github.com/django/django/blob/stable/1.4.x/tests/modeltests/or_lookups/tests.py Comparing objects ================= From 843034a8d653af5b711a4ff79292e46e26717038 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Tue, 26 Mar 2013 12:56:40 -0400 Subject: [PATCH 202/367] Document password truncation with BCryptPasswordHasher --- docs/topics/auth.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/topics/auth.txt b/docs/topics/auth.txt index efc6e7841309..677429db4726 100644 --- a/docs/topics/auth.txt +++ b/docs/topics/auth.txt @@ -462,6 +462,17 @@ To use Bcrypt as your default storage algorithm, do the following: That's it -- now your Django install will use Bcrypt as the default storage algorithm. +.. admonition:: Password truncation with BCryptPasswordHasher + + The designers of bcrypt truncate all passwords at 72 characters which means + that ``bcrypt(password_with_100_chars) == bcrypt(password_with_100_chars[:72])``. + ``BCryptPasswordHasher`` does not have any special handling and + thus is also subject to this hidden password length limit. The practical + ramification of this truncation is pretty marginal as the average user does + not have a password greater than 72 characters in length and even being + truncated at 72 the compute powered required to brute force bcrypt in any + useful amount of time is still astronomical. + .. admonition:: Other bcrypt implementations There are several other implementations that allow bcrypt to be From 4c6fb23dd40216604f914d4f869b40d23b13bf73 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Thu, 28 Mar 2013 15:11:17 -0600 Subject: [PATCH 203/367] [1.4.x] Bump version to no longer claim to be 1.4.5 final. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 08a40178cd35..da2e8384cae6 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 5, 'final', 0) +VERSION = (1, 4, 6, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From d2b883483974c3a45d2f74d40df164b1ae9e00e3 Mon Sep 17 00:00:00 2001 From: Nimesh Ghelani Date: Sat, 30 Mar 2013 01:01:56 +0530 Subject: [PATCH 204/367] [1.4.x] Fixed #20150 -- Fixed an error in manager doc example Backport of 485c024567 from master --- docs/topics/db/managers.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/db/managers.txt b/docs/topics/db/managers.txt index eda6f9dac022..086f9dd09a60 100644 --- a/docs/topics/db/managers.txt +++ b/docs/topics/db/managers.txt @@ -85,7 +85,7 @@ returns a list of all ``OpinionPoll`` objects, each with an extra objects = PollManager() class Response(models.Model): - poll = models.ForeignKey(Poll) + poll = models.ForeignKey(OpinionPoll) person_name = models.CharField(max_length=50) response = models.TextField() From fbac080691760731319cefcb62617a9dd92af0af Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 30 Mar 2013 08:36:31 -0400 Subject: [PATCH 205/367] [1.4.X] Fixed #18277 - Clarified startproject documentation. Backport of 33503600b5 from master --- docs/ref/django-admin.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 0c9461110a09..f6df5d596c8a 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1035,7 +1035,8 @@ through the template engine: the files whose extensions match the with the ``--name`` option. The :class:`template context ` used is: -- Any option passed to the startproject command +- Any option passed to the startapp command (among the command's supported + options) - ``project_name`` -- the project name as passed to the command - ``project_directory`` -- the full path of the newly created project - ``secret_key`` -- a random key for the :setting:`SECRET_KEY` setting From 6297673efda48e72012da5ccea59d6b55cad3eff Mon Sep 17 00:00:00 2001 From: Alex Gaynor Date: Thu, 30 Aug 2012 19:19:11 -0700 Subject: [PATCH 206/367] [1.5.X] Fixed #18883 -- added a missing self parameter in the docs Backport of 17d57275f9 from master --- docs/ref/models/instances.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index 6db096364d4e..1d893212e3c4 100644 --- a/docs/ref/models/instances.txt +++ b/docs/ref/models/instances.txt @@ -48,7 +48,7 @@ that, you need to :meth:`~Model.save()`. 2. Add a method on a custom manager (usually preferred):: class BookManager(models.Manager): - def create_book(title): + def create_book(self, title): book = self.create(title=title) # do something with the book return book From 528345069d8f9f3fb9350e12742fbfefb854fd29 Mon Sep 17 00:00:00 2001 From: Wilfred Hughes Date: Tue, 14 May 2013 11:40:33 +0100 Subject: [PATCH 207/367] [1.4.x] Fixed a minor spelling mistake in the queryset documentation Backport of d258cce482 from master --- docs/ref/models/querysets.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index a32c9f50fd89..022a251e5c45 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -1442,7 +1442,7 @@ internally so that repeated evaluations do not result in additional queries. In contrast, ``iterator()`` will read results directly, without doing any caching at the ``QuerySet`` level (internally, the default iterator calls ``iterator()`` and caches the return value). For a ``QuerySet`` which returns a large number of -objects that you only need to access once, this can results in better +objects that you only need to access once, this can result in better performance and a significant reduction in memory. Note that using ``iterator()`` on a ``QuerySet`` which has already been From e149d8ebf04415fb0bab5b1dda16ef7d196ade7c Mon Sep 17 00:00:00 2001 From: Alasdair Nicol Date: Fri, 24 May 2013 14:36:17 +0100 Subject: [PATCH 208/367] [1.4.x] Updated link to jQuery Cookie plugin site Backport of 81f454a322 from master --- docs/ref/contrib/csrf.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/contrib/csrf.txt b/docs/ref/contrib/csrf.txt index c841e2f522ed..c6d448811db8 100644 --- a/docs/ref/contrib/csrf.txt +++ b/docs/ref/contrib/csrf.txt @@ -120,7 +120,7 @@ Acquiring the token is straightforward: var csrftoken = getCookie('csrftoken'); The above code could be simplified by using the `jQuery cookie plugin -`_ to replace ``getCookie``: +`_ to replace ``getCookie``: .. code-block:: javascript From 1deeda5785d2f7a27ddf8df89640e96ecc9e3e88 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 24 May 2013 12:35:20 -0400 Subject: [PATCH 209/367] [1.5.x] Fixed #20492 - Removed a broken link in GIS docs. Backport of fbab3209fc from master --- docs/ref/contrib/gis/geoip.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/ref/contrib/gis/geoip.txt b/docs/ref/contrib/gis/geoip.txt index 8e8830794b51..6438ba6d0939 100644 --- a/docs/ref/contrib/gis/geoip.txt +++ b/docs/ref/contrib/gis/geoip.txt @@ -17,8 +17,7 @@ Geolocation with GeoIP in ``utils``, but will be removed in Django 1.6. The :class:`GeoIP` object is a ctypes wrapper for the -`MaxMind GeoIP C API`__. [#]_ This interface is a BSD-licensed alternative -to the GPL-licensed `Python GeoIP`__ interface provided by MaxMind. +`MaxMind GeoIP C API`__. [#]_ In order to perform IP-based geolocation, the :class:`GeoIP` object requires the GeoIP C libary and either the GeoIP `Country`__ or `City`__ @@ -29,7 +28,6 @@ you set :setting:`GEOIP_PATH` with in your settings. See the example and reference below for more details. __ http://www.maxmind.com/app/c -__ http://www.maxmind.com/app/python __ http://www.maxmind.com/app/country __ http://www.maxmind.com/app/city __ http://www.maxmind.com/download/geoip/database/ From 227d7f63e4c1797acc3552fdfe994181636fd033 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 28 May 2013 11:54:53 -0400 Subject: [PATCH 210/367] [1.4.x] Fixed #20523 - Incorrect form field for FilePathField. Thanks sane4ka.sh@ for the report. Backport of 1fdc3d256d from master --- docs/topics/forms/modelforms.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index 41953e16ea64..1aa7d6baaa65 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -71,7 +71,7 @@ Model field Form field ``FileField`` ``FileField`` -``FilePathField`` ``CharField`` +``FilePathField`` ``FilePathField`` ``FloatField`` ``FloatField`` From 9b5fe022153c1c9e2ad131905d9b78f5daf7fa9d Mon Sep 17 00:00:00 2001 From: Gavin Wahl Date: Wed, 29 May 2013 16:33:51 -0600 Subject: [PATCH 211/367] =?UTF-8?q?[1.4.x]=C2=A0Fixed=20regroup=20example.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chicago was missing. Backport of e6ff238 from master. --- docs/ref/templates/builtins.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index e547cbc16a92..e911bb167bc8 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -851,6 +851,8 @@ above would result in the following output: * New York: 20,000,000 * India * Calcutta: 15,000,000 +* USA + * Chicago: 7,000,000 * Japan * Tokyo: 33,000,000 From c97cc85b748524d2e0d66c770d485ffeded8e950 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 31 May 2013 08:07:40 -0400 Subject: [PATCH 212/367] [1.4.x] Fixed #20326 - Corrected form wizard get_form() example. Thanks tris@ for the report. Backport of 646a2216e9 from master --- docs/ref/contrib/formtools/form-wizard.txt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/ref/contrib/formtools/form-wizard.txt b/docs/ref/contrib/formtools/form-wizard.txt index 5794393ba44e..1b006e9d4a53 100644 --- a/docs/ref/contrib/formtools/form-wizard.txt +++ b/docs/ref/contrib/formtools/form-wizard.txt @@ -394,8 +394,10 @@ Advanced ``WizardView`` methods .. method:: WizardView.get_form(step=None, data=None, files=None) This method constructs the form for a given ``step``. If no ``step`` is - defined, the current step will be determined automatically. - The method gets three arguments: + defined, the current step will be determined automatically. If you override + ``get_form``, however, you will need to set ``step`` yourself using + ``self.steps.current`` as in the example below. The method gets three + arguments: * ``step`` -- The step for which the form instance should be generated. * ``data`` -- Gets passed to the form's data argument @@ -407,6 +409,11 @@ Advanced ``WizardView`` methods def get_form(self, step=None, data=None, files=None): form = super(MyWizard, self).get_form(step, data, files) + + # determine the step if not given + if step is None: + step = self.steps.current + if step == '1': form.user = self.request.user return form From e3b6fed320f6e67b7e5dc28bab610f342a81f7d7 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Mon, 24 Jun 2013 19:48:23 +0200 Subject: [PATCH 213/367] [1.4.x] Fixed #20636 -- Stopped stuffing values in the settings. In Django < 1.6, override_settings restores the settings module that was active when the override_settings call was executed, not when it was run. This can make a difference when override_settings is applied to a class, since it's executed when the module is imported, not when the test case is run. In addition, if the settings module for tests is stored alongside the tests themselves, importing the settings module can trigger an import of the tests. Since the settings module isn't fully imported yet, class-level override_settings statements may store a reference to an incorrect settings module. Eventually this will result in a crash during test teardown because the settings module restored by override_settings won't the one that was active during test setup. While Django should prevent this situation in the future by failing loudly in such dubious import sequences, that change won't be backported to 1.5 and 1.4. However, these versions received the "allowed hosts" patch and they're prone to "AttributeError: 'Settings' object has no attribute '_original_allowed_hosts'". To mitigate this regression, this commits stuffs _original_allowed_hosts on a random module instead of the settings module. This problem shouldn't occur in Django 1.6, see #20290, but this patch will be forward-ported for extra safety. Also tweaked backup variable names for consistency. Backport of 0261922 from stable/1.5.x. Conflicts: django/test/utils.py --- django/test/utils.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/django/test/utils.py b/django/test/utils.py index 6d6b6e177fc0..e31654e5c636 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -4,6 +4,7 @@ from django.conf import settings, UserSettingsHolder from django.core import mail from django.test.signals import template_rendered, setting_changed +from django.http import request from django.template import Template, loader, TemplateDoesNotExist from django.template.loaders import cached from django.utils.translation import deactivate @@ -69,13 +70,16 @@ def setup_test_environment(): - Set the email backend to the locmem email backend. - Setting the active locale to match the LANGUAGE_CODE setting. """ - Template.original_render = Template._render + Template._original_render = Template._render Template._render = instrumented_test_render - mail.original_email_backend = settings.EMAIL_BACKEND + # Storing previous values in the settings module itself is problematic. + # Store them in arbitrary (but related) modules instead. See #20636. + + mail._original_email_backend = settings.EMAIL_BACKEND settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' - settings._original_allowed_hosts = settings.ALLOWED_HOSTS + request._original_allowed_hosts = settings.ALLOWED_HOSTS settings.ALLOWED_HOSTS = ['*'] mail.outbox = [] @@ -90,14 +94,14 @@ def teardown_test_environment(): - Restoring the email sending functions """ - Template._render = Template.original_render - del Template.original_render + Template._render = Template._original_render + del Template._original_render - settings.EMAIL_BACKEND = mail.original_email_backend - del mail.original_email_backend + settings.EMAIL_BACKEND = mail._original_email_backend + del mail._original_email_backend - settings.ALLOWED_HOSTS = settings._original_allowed_hosts - del settings._original_allowed_hosts + settings.ALLOWED_HOSTS = request._original_allowed_hosts + del request._original_allowed_hosts del mail.outbox From e2b86571bfa3503fe43adfa92e9c9f4271a7a135 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Mon, 24 Jun 2013 21:00:28 +0200 Subject: [PATCH 214/367] [1.4.x] Fixed oversight in e3b6fed3. Refs #20636. --- django/test/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/django/test/utils.py b/django/test/utils.py index e31654e5c636..0988d894822f 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -3,8 +3,8 @@ import warnings from django.conf import settings, UserSettingsHolder from django.core import mail +from django import http from django.test.signals import template_rendered, setting_changed -from django.http import request from django.template import Template, loader, TemplateDoesNotExist from django.template.loaders import cached from django.utils.translation import deactivate @@ -79,7 +79,7 @@ def setup_test_environment(): mail._original_email_backend = settings.EMAIL_BACKEND settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' - request._original_allowed_hosts = settings.ALLOWED_HOSTS + http._original_allowed_hosts = settings.ALLOWED_HOSTS settings.ALLOWED_HOSTS = ['*'] mail.outbox = [] @@ -100,8 +100,8 @@ def teardown_test_environment(): settings.EMAIL_BACKEND = mail._original_email_backend del mail._original_email_backend - settings.ALLOWED_HOSTS = request._original_allowed_hosts - del request._original_allowed_hosts + settings.ALLOWED_HOSTS = http._original_allowed_hosts + del http._original_allowed_hosts del mail.outbox From 165cc1dc2f1cda87f7da65e8a6483318d3ca12be Mon Sep 17 00:00:00 2001 From: Baptiste Mispelon Date: Thu, 27 Jun 2013 09:42:09 +0200 Subject: [PATCH 215/367] [1.4.x] Fixed #20665 -- Missing backslash in sitemaps documentation Backport of 5005303ae7919eef26dab9f8ba279696966ebf1d from master. --- docs/ref/contrib/sitemaps.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/contrib/sitemaps.txt b/docs/ref/contrib/sitemaps.txt index 3e29ec88eb71..8fd07a66b1b9 100644 --- a/docs/ref/contrib/sitemaps.txt +++ b/docs/ref/contrib/sitemaps.txt @@ -318,7 +318,7 @@ with a caching decorator -- you must name your sitemap view and pass from django.views.decorators.cache import cache_page urlpatterns = patterns('', - url(r'^sitemap.xml$', + url(r'^sitemap\.xml$', cache_page(86400)(sitemaps_views.index), {'sitemaps': sitemaps, 'sitemap_url_name': 'sitemaps'}), url(r'^sitemap-(?P

.+)\.xml$', From 7b7592cafaff9b7b06a4949465bb723541ede40a Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 8 Jul 2013 15:01:37 -0400 Subject: [PATCH 216/367] [1.4.x] Fixed #18944 -- Documented PasswordResetForm's from_email argument as a backwards incompatible change for 1.3 Thanks DrMeers for the report. Backport of dab921751d from master --- docs/releases/1.3.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/releases/1.3.txt b/docs/releases/1.3.txt index 3dc90affca18..8ac6d143ba73 100644 --- a/docs/releases/1.3.txt +++ b/docs/releases/1.3.txt @@ -653,6 +653,15 @@ Prior to Django 1.3, inactive users were able to request a password reset email and reset their password. In Django 1.3 inactive users will receive the same message as a nonexistent account. +Password reset view now accepts ``from_email`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :func:`django.contrib.auth.views.password_reset` view now accepts a +``from_email`` parameter, which is passed to the ``password_reset_form``'s +``save()`` method as a keyword argument. If you are using this view with a +custom password reset form, then you will need to ensure your form's ``save()`` +method accepts this keyword argument. + .. _deprecated-features-1.3: Features deprecated in 1.3 From e8971345b4bf0e7ce2124d033ee3385919f47309 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 1 Jul 2013 13:58:04 -0400 Subject: [PATCH 217/367] [1.4.x] Fixed #19196 -- Added test/requirements Backport of 4d92a0bd86 from master --- .../contributing/writing-code/unit-tests.txt | 19 +++++++++++++++++++ tests/requirements/base.txt | 9 +++++++++ tests/requirements/mysql.txt | 1 + tests/requirements/oracle.txt | 1 + tests/requirements/postgres.txt | 1 + 5 files changed, 31 insertions(+) create mode 100644 tests/requirements/base.txt create mode 100644 tests/requirements/mysql.txt create mode 100644 tests/requirements/oracle.txt create mode 100644 tests/requirements/postgres.txt diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index 3e791c09a1e4..06310962dd1e 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -141,29 +141,48 @@ Running all the tests If you want to run the full suite of tests, you'll need to install a number of dependencies: +* PIL_ +* py-bcrypt_ * PyYAML_ * Markdown_ * Textile_ * Docutils_ +* pytz_ * setuptools_ * memcached_, plus a :ref:`supported Python binding ` * gettext_ (:ref:`gettext_on_windows`) * selenium_ (if also using Python >= 2.6) +You can find these dependencies in `pip requirements files`_ inside the +``tests/requirements`` directory of the Django source tree and install them +like so:: + + pip install -r tests/requirements/base.txt + +You can also install the database adapter(s) of your choice using +``oracle.txt``, ``mysql.txt``, or ``postgres.txt``. + If you want to test the memcached cache backend, you'll also need to define a :setting:`CACHES` setting that points at your memcached instance. +To run the GeoDjango tests, you will need to :doc:`setup a spatial database +and install the Geospatial libraries`. + Each of these dependencies is optional. If you're missing any of them, the associated tests will be skipped. +.. _PIL: https://pypi.python.org/pypi/PIL +.. _py-bcrypt: https://pypi.python.org/pypi/py-bcrypt/ .. _PyYAML: http://pyyaml.org/wiki/PyYAML .. _Markdown: http://pypi.python.org/pypi/Markdown/1.7 .. _Textile: http://pypi.python.org/pypi/textile .. _docutils: http://pypi.python.org/pypi/docutils/0.4 +.. _pytz: https://pypi.python.org/pypi/pytz/ .. _setuptools: http://pypi.python.org/pypi/setuptools/ .. _memcached: http://memcached.org/ .. _gettext: http://www.gnu.org/software/gettext/manual/gettext.html .. _selenium: http://pypi.python.org/pypi/selenium +.. _pip requirements files: http://www.pip-installer.org/en/latest/requirements.html Code coverage ~~~~~~~~~~~~~ diff --git a/tests/requirements/base.txt b/tests/requirements/base.txt new file mode 100644 index 000000000000..606a8c7e2b2d --- /dev/null +++ b/tests/requirements/base.txt @@ -0,0 +1,9 @@ +docutils +Markdown +PIL +py-bcrypt +python-memcached +pytz +PyYAML +selenium +Textile diff --git a/tests/requirements/mysql.txt b/tests/requirements/mysql.txt new file mode 100644 index 000000000000..c7a234740719 --- /dev/null +++ b/tests/requirements/mysql.txt @@ -0,0 +1 @@ +MySQL-python diff --git a/tests/requirements/oracle.txt b/tests/requirements/oracle.txt new file mode 100644 index 000000000000..ae5b7349cde3 --- /dev/null +++ b/tests/requirements/oracle.txt @@ -0,0 +1 @@ +cx_oracle diff --git a/tests/requirements/postgres.txt b/tests/requirements/postgres.txt new file mode 100644 index 000000000000..658130bb2c07 --- /dev/null +++ b/tests/requirements/postgres.txt @@ -0,0 +1 @@ +psycopg2 From 288d70fccc802adabd2e20f14b3fe42591680cd4 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 11 Jul 2013 11:06:34 -0400 Subject: [PATCH 218/367] [1.4.x] Fixed #20730 -- Fixed "Programmatically creating permissions" error. Thanks glarrain for the report. Backport of 684a606a4e from master --- docs/topics/auth.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/topics/auth.txt b/docs/topics/auth.txt index 677429db4726..23a4a0c76228 100644 --- a/docs/topics/auth.txt +++ b/docs/topics/auth.txt @@ -1654,10 +1654,11 @@ While custom permissions can be defined within a model's ``Meta`` class, you can also create permissions directly. For example, you can create the ``can_publish`` permission for a ``BlogPost`` model in ``myapp``:: + from myapp.models import BlogPost from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType - content_type = ContentType.objects.get(app_label='myapp', model='BlogPost') + content_type = ContentType.objects.get_for_model(BlogPost) permission = Permission.objects.create(codename='can_publish', name='Can Publish Posts', content_type=content_type) From 6b4b18e7e217acaa35f2d45d97121105f28e3aad Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 17 Jul 2013 06:50:40 -0400 Subject: [PATCH 219/367] [1.4.x] Fixed #20756 -- Typo in uWSGI docs. Backport of a3242dc9fe from master --- docs/howto/deployment/wsgi/uwsgi.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/deployment/wsgi/uwsgi.txt b/docs/howto/deployment/wsgi/uwsgi.txt index 3ac22035448b..e553a7ddc578 100644 --- a/docs/howto/deployment/wsgi/uwsgi.txt +++ b/docs/howto/deployment/wsgi/uwsgi.txt @@ -93,6 +93,6 @@ Example ini configuration file usage:: uwsgi --ini uwsgi.ini See the uWSGI docs on `managing the uWSGI process`_ for information on -starting, stoping and reloading the uWSGI workers. +starting, stopping and reloading the uWSGI workers. .. _managing the uWSGI process: http://projects.unbit.it/uwsgi/wiki/Management From dfe36f10dfecdf0ac54d0a7d3d242e5c8e69e391 Mon Sep 17 00:00:00 2001 From: Matt Deacalion Stevens Date: Thu, 18 Jul 2013 12:53:54 +0100 Subject: [PATCH 220/367] [1.4.x] Atom specification URL updated Changed to the URL of the official RFC for Atom, since Atomenabled.org is just a holding page. Backport of beefc97171 from master --- docs/ref/contrib/syndication.txt | 2 +- docs/ref/utils.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/contrib/syndication.txt b/docs/ref/contrib/syndication.txt index 754ac5843b51..7497bbef9504 100644 --- a/docs/ref/contrib/syndication.txt +++ b/docs/ref/contrib/syndication.txt @@ -17,7 +17,7 @@ you want to generate feeds outside of a Web context, or in some other lower-level way. .. _RSS: http://www.whatisrss.com/ -.. _Atom: http://www.atomenabled.org/ +.. _Atom: http://tools.ietf.org/html/rfc4287 The high-level framework ======================== diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index dd86b3a7b171..9dd0739c390b 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -376,7 +376,7 @@ Atom1Feed .. class:: Atom1Feed(SyndicationFeed) - Spec: http://www.atomenabled.org/developers/syndication/atom-format-spec.php + Spec: http://tools.ietf.org/html/rfc4287 ``django.utils.functional`` =========================== From eda39fe704b1b76a94f44b49949660bef2827ed2 Mon Sep 17 00:00:00 2001 From: Brenton Cleeland Date: Thu, 25 Jul 2013 20:57:49 +1000 Subject: [PATCH 221/367] [1.4.x] Fixed #20792 -- Corrected DISALLOWED_USER_AGENTS docs. Thanks simonb for the report. Backport of dab52d99fc from master --- docs/ref/middleware.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index cb8f737ad263..1bba12e82141 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -37,7 +37,7 @@ defines. See the :doc:`cache documentation `. Adds a few conveniences for perfectionists: * Forbids access to user agents in the :setting:`DISALLOWED_USER_AGENTS` - setting, which should be a list of strings. + setting, which should be a list of compiled regular expression objects. * Performs URL rewriting based on the :setting:`APPEND_SLASH` and :setting:`PREPEND_WWW` settings. From f3a961f009afe4f1be4a5bb6d5c37c98c30fed41 Mon Sep 17 00:00:00 2001 From: mark hellewell Date: Thu, 25 Jul 2013 22:48:22 +1000 Subject: [PATCH 222/367] [1.4.x] Fixed #18315 -- Documented QueryDict.popitem and QueryDict.pop Thanks gcbirzan for the report. Backport of 8c9240222f from master --- docs/ref/request-response.txt | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index d435822a8dc9..e3cc4de62b11 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -492,6 +492,26 @@ In addition, ``QueryDict`` has the following methods: >>> q.lists() [(u'a', [u'1', u'2', u'3'])] +.. method:: QueryDict.pop(key) + + Returns a list of values for the given key and removes them from the + dictionary. Raises ``KeyError`` if the key does not exist. For example:: + + >>> q = QueryDict('a=1&a=2&a=3', mutable=True) + >>> q.pop('a') + [u'1', u'2', u'3'] + +.. method:: QueryDict.popitem() + + Removes an arbitrary member of the dictionary (since there's no concept + of ordering), and returns a two value tuple containing the key and a list + of all values for the key. Raises ``KeyError`` when called on an empty + dictionary. For example:: + + >>> q = QueryDict('a=1&a=2&a=3', mutable=True) + >>> q.popitem() + (u'a', [u'1', u'2', u'3']) + .. method:: QueryDict.dict() .. versionadded:: 1.4 From ed6ec47ff72f5597d53f8c3e3ccd9ba13c360d4e Mon Sep 17 00:00:00 2001 From: SusanTan Date: Tue, 30 Jul 2013 02:11:15 -0700 Subject: [PATCH 223/367] [1.4.x] Fixed #20779 -- Documented AdminSite.app_index_template; refs #8498. Thanks CollinAnderson for the report. Backport of 7de35a9ef3 from master --- docs/ref/contrib/admin/index.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index c403541129d6..96cc5b270d75 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -1870,6 +1870,10 @@ Templates can override or extend base admin templates as described in Path to a custom template that will be used by the admin site main index view. +.. attribute:: AdminSite.app_index_template + + Path to a custom template that will be used by the admin site app index view. + .. attribute:: AdminSite.login_template Path to a custom template that will be used by the admin site login view. From 8af0b1afd2ab0c22497baaae03a5495da9af230d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 31 Jul 2013 09:24:29 -0400 Subject: [PATCH 224/367] [1.4.x] Added a bugfix in docutils 0.11 -- docs will now build properly. Backport of a3a59a3197 from master --- docs/_ext/djangodocs.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/_ext/djangodocs.py b/docs/_ext/djangodocs.py index 3cf00a38e1c5..37f5e20a640e 100644 --- a/docs/_ext/djangodocs.py +++ b/docs/_ext/djangodocs.py @@ -105,9 +105,15 @@ class DjangoHTMLTranslator(SmartyPantsHTMLTranslator): # Don't use border=1, which docutils does by default. def visit_table(self, node): + self.context.append(self.compact_p) + self.compact_p = True self._table_row_index = 0 # Needed by Sphinx self.body.append(self.starttag(node, 'table', CLASS='docutils')) + def depart_table(self, node): + self.compact_p = self.context.pop() + self.body.append('\n') + # ? Really? def visit_desc_parameterlist(self, node): self.body.append('(') From b50be6857ca6779a190e8e42127512618d52591d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 12 Aug 2013 13:20:58 -0400 Subject: [PATCH 225/367] [1.4.x] Added missing release notes for older versions of Django Backport of 3f6cc33cff from master --- docs/releases/1.3.3.txt | 11 ++++++ docs/releases/1.3.4.txt | 37 +++++++++++++++++++ docs/releases/1.3.5.txt | 60 +++++++++++++++++++++++++++++++ docs/releases/1.3.6.txt | 78 +++++++++++++++++++++++++++++++++++++++++ docs/releases/1.3.7.txt | 13 +++++++ docs/releases/1.4.2.txt | 9 ++--- docs/releases/1.4.3.txt | 60 +++++++++++++++++++++++++++++++ docs/releases/1.4.5.txt | 13 +++++++ docs/releases/index.txt | 7 ++++ 9 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 docs/releases/1.3.3.txt create mode 100644 docs/releases/1.3.4.txt create mode 100644 docs/releases/1.3.5.txt create mode 100644 docs/releases/1.3.6.txt create mode 100644 docs/releases/1.3.7.txt create mode 100644 docs/releases/1.4.3.txt create mode 100644 docs/releases/1.4.5.txt diff --git a/docs/releases/1.3.3.txt b/docs/releases/1.3.3.txt new file mode 100644 index 000000000000..437cbfb41258 --- /dev/null +++ b/docs/releases/1.3.3.txt @@ -0,0 +1,11 @@ +========================== +Django 1.3.3 release notes +========================== + +*August 1, 2012* + +Following Monday's security release of :doc:`Django 1.3.2 `, +we began receiving reports that one of the fixes applied was breaking Python +2.4 compatibility for Django 1.3. Since Python 2.4 is a supported Python +version for that release series, this release fixes compatibility with +Python 2.4. diff --git a/docs/releases/1.3.4.txt b/docs/releases/1.3.4.txt new file mode 100644 index 000000000000..3a174b3d448e --- /dev/null +++ b/docs/releases/1.3.4.txt @@ -0,0 +1,37 @@ +========================== +Django 1.3.4 release notes +========================== + +*October 17, 2012* + +This is the fourth release in the Django 1.3 series. + +Host header poisoning +--------------------- + +Some parts of Django -- independent of end-user-written applications -- make +use of full URLs, including domain name, which are generated from the HTTP Host +header. Some attacks against this are beyond Django's ability to control, and +require the web server to be properly configured; Django's documentation has +for some time contained notes advising users on such configuration. + +Django's own built-in parsing of the Host header is, however, still vulnerable, +as was reported to us recently. The Host header parsing in Django 1.3.3 and +Django 1.4.1 -- specifically, ``django.http.HttpRequest.get_host()`` -- was +incorrectly handling username/password information in the header. Thus, for +example, the following Host header would be accepted by Django when running on +"validsite.com":: + + Host: validsite.com:random@evilsite.com + +Using this, an attacker can cause parts of Django -- particularly the +password-reset mechanism -- to generate and display arbitrary URLs to users. + +To remedy this, the parsing in ``HttpRequest.get_host()`` is being modified; +Host headers which contain potentially dangerous content (such as +username/password pairs) now raise the exception +:exc:`django.core.exceptions.SuspiciousOperation`. + +Details of this issue were initially posted online as a `security advisory`_. + +.. _security advisory: https://www.djangoproject.com/weblog/2012/oct/17/security/ diff --git a/docs/releases/1.3.5.txt b/docs/releases/1.3.5.txt new file mode 100644 index 000000000000..65c403209d13 --- /dev/null +++ b/docs/releases/1.3.5.txt @@ -0,0 +1,60 @@ +========================== +Django 1.3.5 release notes +========================== + +*December 10, 2012* + +Django 1.3.5 addresses two security issues present in previous Django releases +in the 1.3 series. + +Please be aware that this security release is slightly different from previous +ones. Both issues addressed here have been dealt with in prior security updates +to Django. In one case, we have received ongoing reports of problems, and in +the other we've chosen to take further steps to tighten up Django's code in +response to independent discovery of potential problems from multiple sources. + +Host header poisoning +--------------------- + +Several earlier Django security releases focused on the issue of poisoning the +HTTP Host header, causing Django to generate URLs pointing to arbitrary, +potentially-malicious domains. + +In response to further input received and reports of continuing issues +following the previous release, we're taking additional steps to tighten Host +header validation. Rather than attempt to accommodate all features HTTP +supports here, Django's Host header validation attempts to support a smaller, +but far more common, subset: + +* Hostnames must consist of characters [A-Za-z0-9] plus hyphen ('-') or dot + ('.'). +* IP addresses -- both IPv4 and IPv6 -- are permitted. +* Port, if specified, is numeric. + +Any deviation from this will now be rejected, raising the exception +:exc:`django.core.exceptions.SuspiciousOperation`. + +Redirect poisoning +------------------ + +Also following up on a previous issue: in July of this year, we made changes to +Django's HTTP redirect classes, performing additional validation of the scheme +of the URL to redirect to (since, both within Django's own supplied +applications and many third-party applications, accepting a user-supplied +redirect target is a common pattern). + +Since then, two independent audits of the code turned up further potential +problems. So, similar to the Host-header issue, we are taking steps to provide +tighter validation in response to reported problems (primarily with third-party +applications, but to a certain extent also within Django itself). This comes in +two parts: + +1. A new utility function, ``django.utils.http.is_safe_url``, is added; this +function takes a URL and a hostname, and checks that the URL is either +relative, or if absolute matches the supplied hostname. This function is +intended for use whenever user-supplied redirect targets are accepted, to +ensure that such redirects cannot lead to arbitrary third-party sites. + +2. All of Django's own built-in views -- primarily in the authentication system +-- which allow user-supplied redirect targets now use ``is_safe_url`` to +validate the supplied URL. diff --git a/docs/releases/1.3.6.txt b/docs/releases/1.3.6.txt new file mode 100644 index 000000000000..d55199a88240 --- /dev/null +++ b/docs/releases/1.3.6.txt @@ -0,0 +1,78 @@ +========================== +Django 1.3.6 release notes +========================== + +*February 19, 2013* + +Django 1.3.6 fixes four security issues present in previous Django releases in +the 1.3 series. + +This is the sixth bugfix/security release in the Django 1.3 series. + + +Host header poisoning +--------------------- + +Some parts of Django -- independent of end-user-written applications -- make +use of full URLs, including domain name, which are generated from the HTTP Host +header. Django's documentation has for some time contained notes advising users +on how to configure webservers to ensure that only valid Host headers can reach +the Django application. However, it has been reported to us that even with the +recommended webserver configurations there are still techniques available for +tricking many common webservers into supplying the application with an +incorrect and possibly malicious Host header. + +For this reason, Django 1.3.6 adds a new setting, ``ALLOWED_HOSTS``, which +should contain an explicit list of valid host/domain names for this site. A +request with a Host header not matching an entry in this list will raise +``SuspiciousOperation`` if ``request.get_host()`` is called. For full details +see the documentation for the :setting:`ALLOWED_HOSTS` setting. + +The default value for this setting in Django 1.3.6 is ``['*']`` (matching any +host), for backwards-compatibility, but we strongly encourage all sites to set +a more restrictive value. + +This host validation is disabled when ``DEBUG`` is ``True`` or when running tests. + + +XML deserialization +------------------- + +The XML parser in the Python standard library is vulnerable to a number of +attacks via external entities and entity expansion. Django uses this parser for +deserializing XML-formatted database fixtures. The fixture deserializer is not +intended for use with untrusted data, but in order to err on the side of safety +in Django 1.3.6 the XML deserializer refuses to parse an XML document with a +DTD (DOCTYPE definition), which closes off these attack avenues. + +These issues in the Python standard library are CVE-2013-1664 and +CVE-2013-1665. More information available `from the Python security team`_. + +Django's XML serializer does not create documents with a DTD, so this should +not cause any issues with the typical round-trip from ``dumpdata`` to +``loaddata``, but if you feed your own XML documents to the ``loaddata`` +management command, you will need to ensure they do not contain a DTD. + +.. _from the Python security team: http://blog.python.org/2013/02/announcing-defusedxml-fixes-for-xml.html + + +Formset memory exhaustion +------------------------- + +Previous versions of Django did not validate or limit the form-count data +provided by the client in a formset's management form, making it possible to +exhaust a server's available memory by forcing it to create very large numbers +of forms. + +In Django 1.3.6, all formsets have a strictly-enforced maximum number of forms +(1000 by default, though it can be set higher via the ``max_num`` formset +factory argument). + + +Admin history view information leakage +-------------------------------------- + +In previous versions of Django, an admin user without change permission on a +model could still view the unicode representation of instances via their admin +history log. Django 1.3.6 now limits the admin history log view for an object +to users with change permission for that model. diff --git a/docs/releases/1.3.7.txt b/docs/releases/1.3.7.txt new file mode 100644 index 000000000000..3cccfcfb1c5e --- /dev/null +++ b/docs/releases/1.3.7.txt @@ -0,0 +1,13 @@ +========================== +Django 1.3.7 release notes +========================== + +*February 20, 2013* + +Django 1.3.7 corrects a packaging problem with yesterday's :doc:`1.3.6 release +`. + +The release contained stray ``.pyc`` files that caused "bad magic number" +errors when running with some versions of Python. This releases corrects this, +and also fixes a bad documentation link in the project template ``settings.py`` +file generated by ``manage.py startproject``. diff --git a/docs/releases/1.4.2.txt b/docs/releases/1.4.2.txt index 07eec39764d0..a6150f56c376 100644 --- a/docs/releases/1.4.2.txt +++ b/docs/releases/1.4.2.txt @@ -17,7 +17,7 @@ for some time contained notes advising users on such configuration. Django's own built-in parsing of the Host header is, however, still vulnerable, as was reported to us recently. The Host header parsing in Django 1.3.3 and -Django 1.4.1 -- specifically, django.http.HttpRequest.get_host() -- was +Django 1.4.1 -- specifically, ``django.http.HttpRequest.get_host()`` -- was incorrectly handling username/password information in the header. Thus, for example, the following Host header would be accepted by Django when running on "validsite.com":: @@ -27,9 +27,10 @@ example, the following Host header would be accepted by Django when running on Using this, an attacker can cause parts of Django -- particularly the password-reset mechanism -- to generate and display arbitrary URLs to users. -To remedy this, the parsing in HttpRequest.get_host() is being modified; Host -headers which contain potentially dangerous content (such as username/password -pairs) now raise the exception django.core.exceptions.SuspiciousOperation +To remedy this, the parsing in ``HttpRequest.get_host()`` is being modified; +Host headers which contain potentially dangerous content (such as +username/password pairs) now raise the exception +:exc:`django.core.exceptions.SuspiciousOperation`. Details of this issue were initially posted online as a `security advisory`_. diff --git a/docs/releases/1.4.3.txt b/docs/releases/1.4.3.txt new file mode 100644 index 000000000000..aadf623c3c74 --- /dev/null +++ b/docs/releases/1.4.3.txt @@ -0,0 +1,60 @@ +========================== +Django 1.4.3 release notes +========================== + +*December 10, 2012* + +Django 1.4.3 addresses two security issues present in previous Django releases +in the 1.4 series. + +Please be aware that this security release is slightly different from previous +ones. Both issues addressed here have been dealt with in prior security updates +to Django. In one case, we have received ongoing reports of problems, and in +the other we've chosen to take further steps to tighten up Django's code in +response to independent discovery of potential problems from multiple sources. + +Host header poisoning +--------------------- + +Several earlier Django security releases focused on the issue of poisoning the +HTTP Host header, causing Django to generate URLs pointing to arbitrary, +potentially-malicious domains. + +In response to further input received and reports of continuing issues +following the previous release, we're taking additional steps to tighten Host +header validation. Rather than attempt to accommodate all features HTTP +supports here, Django's Host header validation attempts to support a smaller, +but far more common, subset: + +* Hostnames must consist of characters [A-Za-z0-9] plus hyphen ('-') or dot + ('.'). +* IP addresses -- both IPv4 and IPv6 -- are permitted. +* Port, if specified, is numeric. + +Any deviation from this will now be rejected, raising the exception +:exc:`django.core.exceptions.SuspiciousOperation`. + +Redirect poisoning +------------------ + +Also following up on a previous issue: in July of this year, we made changes to +Django's HTTP redirect classes, performing additional validation of the scheme +of the URL to redirect to (since, both within Django's own supplied +applications and many third-party applications, accepting a user-supplied +redirect target is a common pattern). + +Since then, two independent audits of the code turned up further potential +problems. So, similar to the Host-header issue, we are taking steps to provide +tighter validation in response to reported problems (primarily with third-party +applications, but to a certain extent also within Django itself). This comes in +two parts: + +1. A new utility function, ``django.utils.http.is_safe_url``, is added; this +function takes a URL and a hostname, and checks that the URL is either +relative, or if absolute matches the supplied hostname. This function is +intended for use whenever user-supplied redirect targets are accepted, to +ensure that such redirects cannot lead to arbitrary third-party sites. + +2. All of Django's own built-in views -- primarily in the authentication system +-- which allow user-supplied redirect targets now use ``is_safe_url`` to +validate the supplied URL. diff --git a/docs/releases/1.4.5.txt b/docs/releases/1.4.5.txt new file mode 100644 index 000000000000..9ba5235f79a4 --- /dev/null +++ b/docs/releases/1.4.5.txt @@ -0,0 +1,13 @@ +========================== +Django 1.4.5 release notes +========================== + +*February 20, 2013* + +Django 1.4.5 corrects a packaging problem with yesterday's :doc:`1.4.4 release +`. + +The release contained stray ``.pyc`` files that caused "bad magic number" +errors when running with some versions of Python. This releases corrects this, +and also fixes a bad documentation link in the project template ``settings.py`` +file generated by ``manage.py startproject``. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 3571e0312674..58cd8cd89c0a 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -20,7 +20,9 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.5 1.4.4 + 1.4.3 1.4.2 1.4.1 1.4 @@ -30,6 +32,11 @@ Final releases .. toctree:: :maxdepth: 1 + 1.3.7 + 1.3.6 + 1.3.5 + 1.3.4 + 1.3.3 1.3.2 1.3.1 1.3 From ec67af0bd609c412b76eaa4cc89968a2a8e5ad6a Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Tue, 13 Aug 2013 11:00:13 -0500 Subject: [PATCH 226/367] Fixed is_safe_url() to reject URLs that use a scheme other than HTTP/S. This is a security fix; disclosure to follow shortly. --- django/contrib/auth/tests/views.py | 8 ++++++-- django/utils/http.py | 7 ++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py index 603d380e9da5..6d7029bae850 100644 --- a/django/contrib/auth/tests/views.py +++ b/django/contrib/auth/tests/views.py @@ -309,7 +309,8 @@ def test_security_check(self, password='password'): for bad_url in ('http://example.com', 'https://example.com', 'ftp://exampel.com', - '//example.com'): + '//example.com', + 'javascript:alert("XSS")'): nasty_url = '%(url)s?%(next)s=%(bad_url)s' % { 'url': login_url, @@ -330,6 +331,7 @@ def test_security_check(self, password='password'): '/view?param=ftp://exampel.com', 'view/?param=//example.com', 'https:///', + 'HTTPS:///', '//testserver/', '/url%20with%20spaces/'): # see ticket #12534 safe_url = '%(url)s?%(next)s=%(good_url)s' % { @@ -467,7 +469,8 @@ def test_security_check(self, password='password'): for bad_url in ('http://example.com', 'https://example.com', 'ftp://exampel.com', - '//example.com'): + '//example.com', + 'javascript:alert("XSS")'): nasty_url = '%(url)s?%(next)s=%(bad_url)s' % { 'url': logout_url, 'next': REDIRECT_FIELD_NAME, @@ -486,6 +489,7 @@ def test_security_check(self, password='password'): '/view?param=ftp://exampel.com', 'view/?param=//example.com', 'https:///', + 'HTTPS:///', '//testserver/', '/url%20with%20spaces/'): # see ticket #12534 safe_url = '%(url)s?%(next)s=%(good_url)s' % { diff --git a/django/utils/http.py b/django/utils/http.py index d2e4eb5adbf0..21c84dc8214a 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -228,11 +228,12 @@ def same_origin(url1, url2): def is_safe_url(url, host=None): """ Return ``True`` if the url is a safe redirection (i.e. it doesn't point to - a different host). + a different host and uses a safe scheme). Always returns ``False`` on an empty url. """ if not url: return False - netloc = urlparse.urlparse(url)[1] - return not netloc or netloc == host + url_info = urlparse.urlparse(url) + return (not url_info[1] or url_info[1] == host) and \ + (not url_info[0] or url_info[0] in ['http', 'https']) From 30e17be1f64f5edaf9200e65748db3c8643b26ad Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Tue, 13 Aug 2013 11:09:05 -0500 Subject: [PATCH 227/367] Bumped version numbers for 1.4.6. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index da2e8384cae6..6f3ea7f63c9a 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 6, 'alpha', 0) +VERSION = (1, 4, 6, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index c81c2e38fa8b..4b819280b6d0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.5' +version = '1.4.6' # The full version, including alpha/beta/rc tags. -release = '1.4.5' +release = '1.4.6' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index 3be1f5f87e19..fbab917f2f49 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.5.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.6.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From e61e20e4979678ee4e48fba81ccd30f70a963700 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 13 Aug 2013 11:16:30 -0500 Subject: [PATCH 228/367] Added 1.4.6/1.5.2 release notes. --- docs/releases/1.4.6.txt | 31 +++++++++++++++++++++ docs/releases/1.5.2.txt | 62 +++++++++++++++++++++++++++++++++++++++++ docs/releases/index.txt | 1 + 3 files changed, 94 insertions(+) create mode 100644 docs/releases/1.4.6.txt create mode 100644 docs/releases/1.5.2.txt diff --git a/docs/releases/1.4.6.txt b/docs/releases/1.4.6.txt new file mode 100644 index 000000000000..575e9fa75a53 --- /dev/null +++ b/docs/releases/1.4.6.txt @@ -0,0 +1,31 @@ +========================== +Django 1.4.6 release notes +========================== + +*August 13, 2013* + +Django 1.4.6 fixes one security issue present in previous Django releases in +the 1.4 series, as well as one other bug. + +This is the sixth bugfix/security release in the Django 1.4 series. + +Mitigated possible XSS attack via user-supplied redirect URLs +------------------------------------------------------------- + +Django relies on user input in some cases (e.g. +:func:`django.contrib.auth.views.login`, :mod:`django.contrib.comments`, and +:doc:`i18n `) to redirect the user to an "on success" URL. +The security checks for these redirects (namely +``django.util.http.is_safe_url()``) didn't check if the scheme is ``http(s)`` +and as such allowed ``javascript:...`` URLs to be entered. If a developer +relied on ``is_safe_url()`` to provide safe redirect targets and put such a +URL into a link, he could suffer from a XSS attack. This bug doesn't affect +Django currently, since we only put this URL into the ``Location`` response +header and browsers seem to ignore JavaScript there. + +Bugfixes +======== + +* Fixed an obscure bug with the :func:`~django.test.utils.override_settings` + decorator. If you hit an ``AttributeError: 'Settings' object has no attribute + '_original_allowed_hosts'`` exception, it's probably fixed (#20636). diff --git a/docs/releases/1.5.2.txt b/docs/releases/1.5.2.txt new file mode 100644 index 000000000000..710f16555ce2 --- /dev/null +++ b/docs/releases/1.5.2.txt @@ -0,0 +1,62 @@ +========================== +Django 1.5.2 release notes +========================== + +*August 13, 2013* + +This is Django 1.5.2, a bugfix and security release for Django 1.5. + +Mitigated possible XSS attack via user-supplied redirect URLs +------------------------------------------------------------- + +Django relies on user input in some cases (e.g. +:func:`django.contrib.auth.views.login`, :mod:`django.contrib.comments`, and +:doc:`i18n `) to redirect the user to an "on success" URL. +The security checks for these redirects (namely +``django.util.http.is_safe_url()``) didn't check if the scheme is ``http(s)`` +and as such allowed ``javascript:...`` URLs to be entered. If a developer +relied on ``is_safe_url()`` to provide safe redirect targets and put such a +URL into a link, he could suffer from a XSS attack. This bug doesn't affect +Django currently, since we only put this URL into the ``Location`` response +header and browsers seem to ignore JavaScript there. + +XSS vulnerability in :mod:`django.contrib.admin` +------------------------------------------------ + +If a :class:`~django.db.models.URLField` is used in Django 1.5, it displays the +current value of the field and a link to the target on the admin change page. +The display routine of this widget was flawed and allowed for XSS. + +Bugfixes +======== + +* Fixed a crash with :meth:`~django.db.models.query.QuerySet.prefetch_related` + (#19607) as well as some ``pickle`` regressions with ``prefetch_related`` + (#20157 and #20257). +* Fixed a regression in :mod:`django.contrib.gis` in the Google Map output on + Python 3 (#20773). +* Made ``DjangoTestSuiteRunner.setup_databases`` properly handle aliases for + the default database (#19940) and prevented ``teardown_databases`` from + attempting to tear down aliases (#20681). +* Fixed the ``django.core.cache.backends.memcached.MemcachedCache`` backend's + ``get_many()`` method on Python 3 (#20722). +* Fixed :mod:`django.contrib.humanize` translation syntax errors. Affected + languages: Mexican Spanish, Mongolian, Romanian, Turkish (#20695). +* Added support for wheel packages (#19252). +* The CSRF token now rotates when a user logs in. +* Some Python 3 compatibility fixes including #20212 and #20025. +* Fixed some rare cases where :meth:`~django.db.models.query.QuerySet.get` + exceptions recursed infinitely (#20278). +* :djadmin:`makemessages` no longer crashes with ``UnicodeDecodeError`` + (#20354). +* Fixed ``geojson`` detection with Spatialite. +* :meth:`~django.test.SimpleTestCase.assertContains` once again works with + binary content (#20237). +* Fixed :class:`~django.db.models.ManyToManyField` if it has a unicode ``name`` + parameter (#20207). +* Ensured that the WSGI request's path is correctly based on the + ``SCRIPT_NAME`` environment variable or the :setting:`FORCE_SCRIPT_NAME` + setting, regardless of whether or not either has a trailing slash (#20169). +* Fixed an obscure bug with the :func:`~django.test.utils.override_settings` + decorator. If you hit an ``AttributeError: 'Settings' object has no attribute + '_original_allowed_hosts'`` exception, it's probably fixed (#20636). diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 58cd8cd89c0a..68384c2ee9e9 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -20,6 +20,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.6 1.4.5 1.4.4 1.4.3 From 506913cdd85fbe52eb29e62e6884b398f0ef1dc2 Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Tue, 13 Aug 2013 11:24:46 -0500 Subject: [PATCH 229/367] Stole the Makefile for building packages from master. --- extras/Makefile | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 extras/Makefile diff --git a/extras/Makefile b/extras/Makefile new file mode 100644 index 000000000000..ff14f404e2cb --- /dev/null +++ b/extras/Makefile @@ -0,0 +1,9 @@ +all: sdist bdist_wheel + +sdist: + python setup.py sdist + +bdist_wheel: + python -c "import setuptools;__file__='setup.py';exec(compile(open(__file__).read().replace('\\r\\n', '\\n'), __file__, 'exec'))" bdist_wheel + +.PHONY : sdist bdist_wheel From d77ce64fe89fed76a3f9827c2e385ff1f1f2f8f3 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 13 Aug 2013 13:15:54 -0400 Subject: [PATCH 230/367] [1.4.x] Removed 1.5.2 release notes --- docs/releases/1.5.2.txt | 62 ----------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 docs/releases/1.5.2.txt diff --git a/docs/releases/1.5.2.txt b/docs/releases/1.5.2.txt deleted file mode 100644 index 710f16555ce2..000000000000 --- a/docs/releases/1.5.2.txt +++ /dev/null @@ -1,62 +0,0 @@ -========================== -Django 1.5.2 release notes -========================== - -*August 13, 2013* - -This is Django 1.5.2, a bugfix and security release for Django 1.5. - -Mitigated possible XSS attack via user-supplied redirect URLs -------------------------------------------------------------- - -Django relies on user input in some cases (e.g. -:func:`django.contrib.auth.views.login`, :mod:`django.contrib.comments`, and -:doc:`i18n `) to redirect the user to an "on success" URL. -The security checks for these redirects (namely -``django.util.http.is_safe_url()``) didn't check if the scheme is ``http(s)`` -and as such allowed ``javascript:...`` URLs to be entered. If a developer -relied on ``is_safe_url()`` to provide safe redirect targets and put such a -URL into a link, he could suffer from a XSS attack. This bug doesn't affect -Django currently, since we only put this URL into the ``Location`` response -header and browsers seem to ignore JavaScript there. - -XSS vulnerability in :mod:`django.contrib.admin` ------------------------------------------------- - -If a :class:`~django.db.models.URLField` is used in Django 1.5, it displays the -current value of the field and a link to the target on the admin change page. -The display routine of this widget was flawed and allowed for XSS. - -Bugfixes -======== - -* Fixed a crash with :meth:`~django.db.models.query.QuerySet.prefetch_related` - (#19607) as well as some ``pickle`` regressions with ``prefetch_related`` - (#20157 and #20257). -* Fixed a regression in :mod:`django.contrib.gis` in the Google Map output on - Python 3 (#20773). -* Made ``DjangoTestSuiteRunner.setup_databases`` properly handle aliases for - the default database (#19940) and prevented ``teardown_databases`` from - attempting to tear down aliases (#20681). -* Fixed the ``django.core.cache.backends.memcached.MemcachedCache`` backend's - ``get_many()`` method on Python 3 (#20722). -* Fixed :mod:`django.contrib.humanize` translation syntax errors. Affected - languages: Mexican Spanish, Mongolian, Romanian, Turkish (#20695). -* Added support for wheel packages (#19252). -* The CSRF token now rotates when a user logs in. -* Some Python 3 compatibility fixes including #20212 and #20025. -* Fixed some rare cases where :meth:`~django.db.models.query.QuerySet.get` - exceptions recursed infinitely (#20278). -* :djadmin:`makemessages` no longer crashes with ``UnicodeDecodeError`` - (#20354). -* Fixed ``geojson`` detection with Spatialite. -* :meth:`~django.test.SimpleTestCase.assertContains` once again works with - binary content (#20237). -* Fixed :class:`~django.db.models.ManyToManyField` if it has a unicode ``name`` - parameter (#20207). -* Ensured that the WSGI request's path is correctly based on the - ``SCRIPT_NAME`` environment variable or the :setting:`FORCE_SCRIPT_NAME` - setting, regardless of whether or not either has a trailing slash (#20169). -* Fixed an obscure bug with the :func:`~django.test.utils.override_settings` - decorator. If you hit an ``AttributeError: 'Settings' object has no attribute - '_original_allowed_hosts'`` exception, it's probably fixed (#20636). From 0d4ef66f7cd61ebceecd1642ac2f863fb210be7a Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Tue, 13 Aug 2013 12:16:28 -0500 Subject: [PATCH 231/367] Bump version post-release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 6f3ea7f63c9a..18700b6f58e3 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 6, 'final', 0) +VERSION = (1, 4, 7, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 08e5fcb3e641f7f0af873910117a4951fb2719b7 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Tue, 13 Aug 2013 22:34:52 +0200 Subject: [PATCH 232/367] Fixed regression in validation tests since example.com is available via https now. --- tests/modeltests/validation/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/modeltests/validation/tests.py b/tests/modeltests/validation/tests.py index 5c94f439d16d..3078089f96dc 100644 --- a/tests/modeltests/validation/tests.py +++ b/tests/modeltests/validation/tests.py @@ -85,6 +85,7 @@ def test_correct_url_with_redirect(self): mtv = ModelToValidate(number=10, name='Some Name', url_verify='http://qa-dev.w3.org/link-testsuite/http.php?code=301') #example.com is a redirect to iana.org now self.assertEqual(None, mtv.full_clean()) # This will fail if there's no Internet connection + @verify_exists_urls(existing_urls=()) def test_correct_https_url_but_nonexisting(self): mtv = ModelToValidate(number=10, name='Some Name', url_verify='https://www.example.com/') self.assertFieldFailsValidationWithMessage(mtv.full_clean, 'url_verify', [u'This URL appears to be a broken link.']) From bf611f14ec13312aa822beec72c63bd04950613c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Sun, 29 Apr 2012 19:48:43 +0300 Subject: [PATCH 233/367] [1.4.x] Fixed #20905 -- Fixed an Oracle-specific test case failure Made a test checking ORM-generated query string case-insensitive. Backport of ee0a7c741e from master --- tests/modeltests/prefetch_related/tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/modeltests/prefetch_related/tests.py b/tests/modeltests/prefetch_related/tests.py index f48630ad788c..7f494febfa9c 100644 --- a/tests/modeltests/prefetch_related/tests.py +++ b/tests/modeltests/prefetch_related/tests.py @@ -364,7 +364,9 @@ def test_child_link_prefetch(self): l = [a.authorwithage for a in Author.objects.prefetch_related('authorwithage')] # Regression for #18090: the prefetching query must include an IN clause. - self.assertIn('authorwithage', connection.queries[-1]['sql']) + # Note that on Oracle the table name is upper case in the generated SQL, + # thus the .lower() call. + self.assertIn('authorwithage', connection.queries[-1]['sql'].lower()) self.assertIn(' IN ', connection.queries[-1]['sql']) self.assertEqual(l, [a.authorwithage for a in Author.objects.all()]) From d5da495a2edc741b6496821baa0d6bcee9dce9bb Mon Sep 17 00:00:00 2001 From: Luke Plant Date: Mon, 24 Dec 2012 01:33:44 +0000 Subject: [PATCH 234/367] [1.4.x] Fixed #20906 -- Fixed a dependence on set-ordering in tests Backport of 1ae64e96c1 from master --- tests/regressiontests/admin_filters/tests.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/regressiontests/admin_filters/tests.py b/tests/regressiontests/admin_filters/tests.py index c75ba6c23d57..7eb0c6ab0aa3 100644 --- a/tests/regressiontests/admin_filters/tests.py +++ b/tests/regressiontests/admin_filters/tests.py @@ -82,11 +82,11 @@ class DepartmentListFilterLookupWithNonStringValue(SimpleListFilter): parameter_name = 'department' def lookups(self, request, model_admin): - return set([ + return sorted(set([ (employee.department.id, # Intentionally not a string (Refs #19318) employee.department.code) for employee in model_admin.queryset(request).all() - ]) + ])) def queryset(self, request, queryset): if self.value(): @@ -681,10 +681,9 @@ def test_lookup_with_non_string_value(self): filterspec = changelist.get_filters(request)[0][-1] self.assertEqual(force_unicode(filterspec.title), u'department') choices = list(filterspec.choices(changelist)) - - self.assertEqual(choices[2]['display'], u'DEV') - self.assertEqual(choices[2]['selected'], True) - self.assertEqual(choices[2]['query_string'], '?department=%s' % self.john.pk) + self.assertEqual(choices[1]['display'], 'DEV') + self.assertEqual(choices[1]['selected'], True) + self.assertEqual(choices[1]['query_string'], '?department=%s' % self.john.pk) def test_fk_with_to_field(self): """ From d9dc98159d0454d74aebec3e9a763ddecea0961e Mon Sep 17 00:00:00 2001 From: Shai Berger Date: Thu, 15 Aug 2013 03:30:51 +0300 Subject: [PATCH 235/367] [1.4.x] Fixed #20904: Test failure on Oracle Just skip the failing test, the failure isn't really relevant; also, both the test and the reason for its failure were removed in 1.5. Thanks Tim Graham for advice on 1.5. --- tests/regressiontests/test_runner/tests.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/regressiontests/test_runner/tests.py b/tests/regressiontests/test_runner/tests.py index 22e9fe6af511..7f54522bcaeb 100644 --- a/tests/regressiontests/test_runner/tests.py +++ b/tests/regressiontests/test_runner/tests.py @@ -10,7 +10,7 @@ from django.core.exceptions import ImproperlyConfigured from django.core.management import call_command from django import db -from django.test import simple +from django.test import simple, skipIfDBFeature from django.test.simple import DjangoTestSuiteRunner, get_tests from django.test.testcases import connections_support_transactions from django.test.utils import get_warnings_state, restore_warnings_state @@ -217,6 +217,13 @@ def test_all_options_given(self): class Ticket16885RegressionTests(unittest.TestCase): + + # Skipped if empty strings are nulls because this feature causes + # database setup to fail on model validation for models defined + # with string PKs (such models are already in the AppCache), while + # the test cares neither about models nor about the database backend + # from settings. + @skipIfDBFeature('interprets_empty_strings_as_nulls') def test_ticket_16885(self): """Features are also confirmed on mirrored databases.""" old_db_connections = db.connections From 7826824aef42097e265ad5132bfbff4c64762dd7 Mon Sep 17 00:00:00 2001 From: Shai Berger Date: Sun, 18 Aug 2013 01:45:01 +0300 Subject: [PATCH 236/367] [1.4.x] Fixed #20907 - Test failure on Oracle Backport of the Oracle-specific part of commit a18e43c5bb8cb7c82 from master. This commit made get_indexes more consistent across backends. Thanks Tim Graham for pointer to the commit, akaariai and ikelly for the original commit. --- django/db/backends/oracle/introspection.py | 57 ++++++++++------------ 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/django/db/backends/oracle/introspection.py b/django/db/backends/oracle/introspection.py index b8a8b2e2c1f1..7d477f6924b3 100644 --- a/django/db/backends/oracle/introspection.py +++ b/django/db/backends/oracle/introspection.py @@ -72,14 +72,14 @@ def get_relations(self, cursor, table_name): FROM user_constraints, USER_CONS_COLUMNS ca, USER_CONS_COLUMNS cb, user_tab_cols ta, user_tab_cols tb WHERE user_constraints.table_name = %s AND - ta.table_name = %s AND + ta.table_name = user_constraints.table_name AND ta.column_name = ca.column_name AND - ca.table_name = %s AND + ca.table_name = ta.table_name AND user_constraints.constraint_name = ca.constraint_name AND user_constraints.r_constraint_name = cb.constraint_name AND cb.table_name = tb.table_name AND cb.column_name = tb.column_name AND - ca.position = cb.position""", [table_name, table_name, table_name]) + ca.position = cb.position""", [table_name]) relations = {} for row in cursor.fetchall(): @@ -87,36 +87,31 @@ def get_relations(self, cursor, table_name): return relations def get_indexes(self, cursor, table_name): + sql = """ + SELECT LOWER(uic1.column_name) AS column_name, + CASE user_constraints.constraint_type + WHEN 'P' THEN 1 ELSE 0 + END AS is_primary_key, + CASE user_indexes.uniqueness + WHEN 'UNIQUE' THEN 1 ELSE 0 + END AS is_unique + FROM user_constraints, user_indexes, user_ind_columns uic1 + WHERE user_constraints.constraint_type (+) = 'P' + AND user_constraints.index_name (+) = uic1.index_name + AND user_indexes.uniqueness (+) = 'UNIQUE' + AND user_indexes.index_name (+) = uic1.index_name + AND uic1.table_name = UPPER(%s) + AND uic1.column_position = 1 + AND NOT EXISTS ( + SELECT 1 + FROM user_ind_columns uic2 + WHERE uic2.index_name = uic1.index_name + AND uic2.column_position = 2 + ) """ - Returns a dictionary of fieldname -> infodict for the given table, - where each infodict is in the format: - {'primary_key': boolean representing whether it's the primary key, - 'unique': boolean representing whether it's a unique index} - """ - # This query retrieves each index on the given table, including the - # first associated field name - # "We were in the nick of time; you were in great peril!" - sql = """\ -SELECT LOWER(all_tab_cols.column_name) AS column_name, - CASE user_constraints.constraint_type - WHEN 'P' THEN 1 ELSE 0 - END AS is_primary_key, - CASE user_indexes.uniqueness - WHEN 'UNIQUE' THEN 1 ELSE 0 - END AS is_unique -FROM all_tab_cols, user_cons_columns, user_constraints, user_ind_columns, user_indexes -WHERE all_tab_cols.column_name = user_cons_columns.column_name (+) - AND all_tab_cols.table_name = user_cons_columns.table_name (+) - AND user_cons_columns.constraint_name = user_constraints.constraint_name (+) - AND user_constraints.constraint_type (+) = 'P' - AND user_ind_columns.column_name (+) = all_tab_cols.column_name - AND user_ind_columns.table_name (+) = all_tab_cols.table_name - AND user_indexes.uniqueness (+) = 'UNIQUE' - AND user_indexes.index_name (+) = user_ind_columns.index_name - AND all_tab_cols.table_name = UPPER(%s) -""" cursor.execute(sql, [table_name]) indexes = {} for row in cursor.fetchall(): - indexes[row[0]] = {'primary_key': row[1], 'unique': row[2]} + indexes[row[0]] = {'primary_key': bool(row[1]), + 'unique': bool(row[2])} return indexes From 9ab7ed9b726a2bb0eee1d89327b9bf7ea75bba38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B4=D0=BE=D0=B2=D1=81=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B9?= Date: Sat, 6 Jul 2013 09:38:33 +0700 Subject: [PATCH 237/367] [1.4.x] Fixed #20707 -- Added explicit quota assignment to Oracle test user To enable testing on Oracle 12c --- django/db/backends/oracle/creation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/django/db/backends/oracle/creation.py b/django/db/backends/oracle/creation.py index 9e6133fc6424..9d4f9b1a33c5 100644 --- a/django/db/backends/oracle/creation.py +++ b/django/db/backends/oracle/creation.py @@ -167,6 +167,7 @@ def _create_test_user(self, cursor, parameters, verbosity): IDENTIFIED BY %(password)s DEFAULT TABLESPACE %(tblspace)s TEMPORARY TABLESPACE %(tblspace_temp)s + QUOTA UNLIMITED ON %(tblspace)s """, """GRANT CONNECT, RESOURCE TO %(user)s""", ] From 87d2750b39f6f2d54b7047225521a44dcd37e896 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 27 Aug 2013 21:06:33 -0400 Subject: [PATCH 238/367] [1.4.x] Prevented arbitrary file inclusion with {% ssi %} tag and relative paths. Thanks Rainer Koirikivi for the report and draft patch. This is a security fix; disclosure to follow shortly. Backport of 7fe5b656c9 from master --- django/template/defaulttags.py | 2 ++ tests/regressiontests/templates/tests.py | 31 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/django/template/defaulttags.py b/django/template/defaulttags.py index 954c5d6d1917..f977901e9376 100644 --- a/django/template/defaulttags.py +++ b/django/template/defaulttags.py @@ -1,5 +1,6 @@ """Default tags used by the template system, available to all templates.""" +import os import sys import re from datetime import datetime @@ -309,6 +310,7 @@ def render(self, context): return '' def include_is_allowed(filepath): + filepath = os.path.abspath(filepath) for root in settings.ALLOWED_INCLUDE_ROOTS: if filepath.startswith(root): return True diff --git a/tests/regressiontests/templates/tests.py b/tests/regressiontests/templates/tests.py index f74aa757e6a1..6b02c83cb86e 100644 --- a/tests/regressiontests/templates/tests.py +++ b/tests/regressiontests/templates/tests.py @@ -1764,3 +1764,34 @@ def test_include_only(self): template.Template('{% include "child" only %}').render(ctx), 'none' ) + + +class SSITests(unittest.TestCase): + def setUp(self): + self.this_dir = os.path.dirname(os.path.abspath(__file__)) + self.ssi_dir = os.path.join(self.this_dir, "templates", "first") + + def render_ssi(self, path): + # the path must exist for the test to be reliable + self.assertTrue(os.path.exists(path)) + return template.Template('{%% ssi %s %%}' % path).render(Context()) + + def test_allowed_paths(self): + acceptable_path = os.path.join(self.ssi_dir, "..", "first", "test.html") + with override_settings(ALLOWED_INCLUDE_ROOTS=(self.ssi_dir,)): + self.assertEqual(self.render_ssi(acceptable_path), 'First template\n') + + def test_relative_include_exploit(self): + """ + May not bypass ALLOWED_INCLUDE_ROOTS with relative paths + + e.g. if ALLOWED_INCLUDE_ROOTS = ("/var/www",), it should not be + possible to do {% ssi "/var/www/../../etc/passwd" %} + """ + disallowed_paths = [ + os.path.join(self.ssi_dir, "..", "ssi_include.html"), + os.path.join(self.ssi_dir, "..", "second", "test.html"), + ] + with override_settings(ALLOWED_INCLUDE_ROOTS=(self.ssi_dir,)): + for path in disallowed_paths: + self.assertEqual(self.render_ssi(path), '') From d1dc8a0d009436c76321f0a70addf679ebf6ff56 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 23 Aug 2013 06:49:37 -0400 Subject: [PATCH 239/367] Added 1.4.7 release notes Backport of baec6a26dd from master --- docs/releases/1.4.7.txt | 25 +++++++++++++++++++++++++ docs/releases/index.txt | 1 + 2 files changed, 26 insertions(+) create mode 100644 docs/releases/1.4.7.txt diff --git a/docs/releases/1.4.7.txt b/docs/releases/1.4.7.txt new file mode 100644 index 000000000000..64d308894cf3 --- /dev/null +++ b/docs/releases/1.4.7.txt @@ -0,0 +1,25 @@ +========================== +Django 1.4.7 release notes +========================== + +*September 10, 2013* + +Django 1.4.7 fixes one security issue present in previous Django releases in +the 1.4 series. + +Directory traversal vulnerability in :ttag:`ssi` template tag +------------------------------------------------------------- + +In previous versions of Django it was possible to bypass the +:setting:`ALLOWED_INCLUDE_ROOTS` setting used for security with the :ttag:`ssi` +template tag by specifying a relative path that starts with one of the allowed +roots. For example, if ``ALLOWED_INCLUDE_ROOTS = ("/var/www",)`` the following +would be possible: + +.. code-block:: html+django + + {% ssi "/var/www/../../etc/passwd" %} + +In practice this is not a very common problem, as it would require the template +author to put the :ttag:`ssi` file in a user-controlled variable, but it's +possible in principle. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 68384c2ee9e9..0a4198bcb55a 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -20,6 +20,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.7 1.4.6 1.4.5 1.4.4 From 701c1a11bc1ea94e48199f4c0711472fbcfd99c3 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Tue, 10 Sep 2013 20:15:38 -0500 Subject: [PATCH 240/367] [1.4.x] Bump version numbers for 1.4.7 security release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 18700b6f58e3..ad2e79dae847 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 7, 'alpha', 0) +VERSION = (1, 4, 7, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 4b819280b6d0..fde4a58d9aaa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.6' +version = '1.4.7' # The full version, including alpha/beta/rc tags. -release = '1.4.6' +release = '1.4.7' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index fbab917f2f49..91fe94a16a19 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.6.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.7.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 3203f684e8e51cbfa1b39d7b6a56e340981ad4d5 Mon Sep 17 00:00:00 2001 From: Loic Bistuer Date: Wed, 11 Sep 2013 18:05:39 +0700 Subject: [PATCH 241/367] Fixed failing test introduced by 87d2750b39. The {% ssi %} tag in Django 1.4 doesn't support spaces in its argument. Skip the test if run from a location that contains a space. --- tests/regressiontests/templates/tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/regressiontests/templates/tests.py b/tests/regressiontests/templates/tests.py index 6b02c83cb86e..d3defb81832c 100644 --- a/tests/regressiontests/templates/tests.py +++ b/tests/regressiontests/templates/tests.py @@ -1766,6 +1766,8 @@ def test_include_only(self): ) +@unittest.skipIf(' ' in __file__, + "The {%% ssi %%} tag in Django 1.4 doesn't support spaces in path.") class SSITests(unittest.TestCase): def setUp(self): self.this_dir = os.path.dirname(os.path.abspath(__file__)) From fba6af5a1ed5924f400666f6747e706a20cceadc Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 11 Sep 2013 07:02:07 -0400 Subject: [PATCH 242/367] [1.4.x] Bump version post-release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index ad2e79dae847..143dc69568fa 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 7, 'final', 0) +VERSION = (1, 4, 8, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From cca302cde6b524992d89add9b9f293d86ac8fba0 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 11 Sep 2013 08:17:15 -0400 Subject: [PATCH 243/367] [1.4.x] Fixed #20887 -- Added a warning to GzipMiddleware in light of BREACH. Thanks EvilDMP for the report and Russell Keith-Magee for the draft text. Backport of da843e7dba from master --- docs/ref/middleware.txt | 14 ++++++++++++++ docs/topics/cache.txt | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index 1bba12e82141..a9c67dbf11e4 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -90,6 +90,20 @@ GZip middleware .. class:: GZipMiddleware +.. warning:: + + Security researchers recently revealed that when compression techniques + (including ``GZipMiddleware``) are used on a website, the site becomes + exposed to a number of possible attacks. These approaches can be used to + compromise, amongst other things, Django's CSRF protection. Before using + ``GZipMiddleware`` on your site, you should consider very carefully whether + you are subject to these attacks. If you're in *any* doubt about whether + you're affected, you should avoid using ``GZipMiddleware``. For more + details, see the `the BREACH paper (PDF)`_ and `breachattack.com`_. + + .. _the BREACH paper (PDF): http://breachattack.com/resources/BREACH%20-%20SSL,%20gone%20in%2030%20seconds.pdf + .. _breachattack.com: http://breachattack.com + Compresses content for browsers that understand GZip compression (all modern browsers). diff --git a/docs/topics/cache.txt b/docs/topics/cache.txt index 99d764b60d3c..fa0a18c8f714 100644 --- a/docs/topics/cache.txt +++ b/docs/topics/cache.txt @@ -1164,7 +1164,10 @@ site's performance: and ``Last-Modified`` headers. * :class:`django.middleware.gzip.GZipMiddleware` compresses responses for all - modern browsers, saving bandwidth and transfer time. + modern browsers, saving bandwidth and transfer time. Be warned, however, + that compression techniques like ``GZipMiddleware`` are subject to attacks. + See the warning in :class:`~django.middleware.gzip.GZipMiddleware` for + details. Order of MIDDLEWARE_CLASSES =========================== From 75d2bcda10f00366e6d847f2c90db3e772433e46 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 2 Aug 2013 14:46:17 -0400 Subject: [PATCH 244/367] Fixed #18923 -- Corrected usage of sensitive_post_parameters in contrib.auth Thanks Collin Anderson for the report. Backport of 425d076d0c from master --- django/contrib/auth/admin.py | 7 ++++--- django/views/decorators/debug.py | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/django/contrib/auth/admin.py b/django/contrib/auth/admin.py index f14b3d219b7d..336e90a27b33 100644 --- a/django/contrib/auth/admin.py +++ b/django/contrib/auth/admin.py @@ -17,6 +17,8 @@ from django.views.decorators.debug import sensitive_post_parameters csrf_protect_m = method_decorator(csrf_protect) +sensitive_post_parameters_m = method_decorator(sensitive_post_parameters()) + class GroupAdmin(admin.ModelAdmin): search_fields = ('name',) @@ -83,7 +85,7 @@ def get_urls(self): self.admin_site.admin_view(self.user_change_password)) ) + super(UserAdmin, self).get_urls() - @sensitive_post_parameters() + @sensitive_post_parameters_m @csrf_protect_m @transaction.commit_on_success def add_view(self, request, form_url='', extra_context=None): @@ -113,7 +115,7 @@ def add_view(self, request, form_url='', extra_context=None): return super(UserAdmin, self).add_view(request, form_url, extra_context) - @sensitive_post_parameters() + @sensitive_post_parameters_m def user_change_password(self, request, id, form_url=''): if not self.has_change_permission(request): raise PermissionDenied @@ -170,4 +172,3 @@ def response_add(self, request, obj, post_url_continue='../%s/'): admin.site.register(Group, GroupAdmin) admin.site.register(User, UserAdmin) - diff --git a/django/views/decorators/debug.py b/django/views/decorators/debug.py index 5c222963d379..381e9dd722d1 100644 --- a/django/views/decorators/debug.py +++ b/django/views/decorators/debug.py @@ -1,5 +1,7 @@ import functools +from django.http import HttpRequest + def sensitive_variables(*variables): """ @@ -62,6 +64,10 @@ def my_view(request) def decorator(view): @functools.wraps(view) def sensitive_post_parameters_wrapper(request, *args, **kwargs): + assert isinstance(request, HttpRequest), ( + "sensitive_post_parameters didn't receive an HttpRequest. If you " + "are decorating a classmethod, be sure to use @method_decorator." + ) if parameters: request.sensitive_post_parameters = parameters else: From 3f3d887a6844ec2db743fee64c9e53e04d39a368 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 15 Sep 2013 13:49:16 +0800 Subject: [PATCH 245/367] [1.4.x] Ensure that passwords are never long enough for a DoS. * Limit the password length to 4096 bytes * Password hashers will raise a ValueError * django.contrib.auth forms will fail validation * Document in release notes that this is a backwards incompatible change Thanks to Josh Wright for the report, and Donald Stufft for the patch. This is a security fix; disclosure to follow shortly. Backport of aae5a96d5754ad34e48b7f673ef2411a3bbc1015 from master. --- django/contrib/auth/forms.py | 51 ++++++++++++++------ django/contrib/auth/hashers.py | 29 ++++++++++- django/contrib/auth/tests/hashers.py | 72 +++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 16 deletions(-) diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index f0ef124b204e..2d1c9bb761f3 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -8,7 +8,10 @@ from django.contrib.auth import authenticate from django.contrib.auth.models import User -from django.contrib.auth.hashers import UNUSABLE_PASSWORD, is_password_usable, get_hasher +from django.contrib.auth.hashers import ( + MAXIMUM_PASSWORD_LENGTH, UNUSABLE_PASSWORD, + is_password_usable, get_hasher +) from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.models import get_current_site @@ -70,10 +73,11 @@ class UserCreationForm(forms.ModelForm): 'invalid': _("This value may contain only letters, numbers and " "@/./+/-/_ characters.")}) password1 = forms.CharField(label=_("Password"), - widget=forms.PasswordInput) + widget=forms.PasswordInput, max_length=MAXIMUM_PASSWORD_LENGTH) password2 = forms.CharField(label=_("Password confirmation"), widget=forms.PasswordInput, - help_text = _("Enter the same password as above, for verification.")) + max_length=MAXIMUM_PASSWORD_LENGTH, + help_text=_("Enter the same password as above, for verification.")) class Meta: model = User @@ -137,7 +141,11 @@ class AuthenticationForm(forms.Form): username/password logins. """ username = forms.CharField(label=_("Username"), max_length=30) - password = forms.CharField(label=_("Password"), widget=forms.PasswordInput) + password = forms.CharField( + label=_("Password"), + widget=forms.PasswordInput, + max_length=MAXIMUM_PASSWORD_LENGTH, + ) error_messages = { 'invalid_login': _("Please enter a correct username and password. " @@ -250,10 +258,16 @@ class SetPasswordForm(forms.Form): error_messages = { 'password_mismatch': _("The two password fields didn't match."), } - new_password1 = forms.CharField(label=_("New password"), - widget=forms.PasswordInput) - new_password2 = forms.CharField(label=_("New password confirmation"), - widget=forms.PasswordInput) + new_password1 = forms.CharField( + label=_("New password"), + widget=forms.PasswordInput, + max_length=MAXIMUM_PASSWORD_LENGTH, + ) + new_password2 = forms.CharField( + label=_("New password confirmation"), + widget=forms.PasswordInput, + max_length=MAXIMUM_PASSWORD_LENGTH, + ) def __init__(self, user, *args, **kwargs): self.user = user @@ -284,8 +298,11 @@ class PasswordChangeForm(SetPasswordForm): 'password_incorrect': _("Your old password was entered incorrectly. " "Please enter it again."), }) - old_password = forms.CharField(label=_("Old password"), - widget=forms.PasswordInput) + old_password = forms.CharField( + label=_("Old password"), + widget=forms.PasswordInput, + max_length=MAXIMUM_PASSWORD_LENGTH, + ) def clean_old_password(self): """ @@ -307,10 +324,16 @@ class AdminPasswordChangeForm(forms.Form): error_messages = { 'password_mismatch': _("The two password fields didn't match."), } - password1 = forms.CharField(label=_("Password"), - widget=forms.PasswordInput) - password2 = forms.CharField(label=_("Password (again)"), - widget=forms.PasswordInput) + password1 = forms.CharField( + label=_("Password"), + widget=forms.PasswordInput, + max_length=MAXIMUM_PASSWORD_LENGTH, + ) + password2 = forms.CharField( + label=_("Password (again)"), + widget=forms.PasswordInput, + max_length=MAXIMUM_PASSWORD_LENGTH, + ) def __init__(self, user, *args, **kwargs): self.user = user diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index a9dbcc9568e1..2d7e7bd9c0f4 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -1,3 +1,4 @@ +import functools import hashlib from django.conf import settings @@ -11,10 +12,23 @@ UNUSABLE_PASSWORD = '!' # This will never be a valid encoded hash +MAXIMUM_PASSWORD_LENGTH = 4096 # The maximum length a password can be to prevent DoS HASHERS = None # lazily loaded from PASSWORD_HASHERS PREFERRED_HASHER = None # defaults to first item in PASSWORD_HASHERS +def password_max_length(max_length): + def inner(fn): + @functools.wraps(fn) + def wrapper(self, password, *args, **kwargs): + if len(password) > max_length: + raise ValueError("Invalid password; Must be less than or equal" + " to %d bytes" % max_length) + return fn(self, password, *args, **kwargs) + return wrapper + return inner + + def is_password_usable(encoded): return (encoded is not None and encoded != UNUSABLE_PASSWORD) @@ -202,6 +216,7 @@ class PBKDF2PasswordHasher(BasePasswordHasher): iterations = 10000 digest = hashlib.sha256 + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt, iterations=None): assert password assert salt and '$' not in salt @@ -211,6 +226,7 @@ def encode(self, password, salt, iterations=None): hash = hash.encode('base64').strip() return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash) + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): algorithm, iterations, salt, hash = encoded.split('$', 3) assert algorithm == self.algorithm @@ -256,11 +272,13 @@ def salt(self): bcrypt = self._load_library() return bcrypt.gensalt(self.rounds) + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): bcrypt = self._load_library() data = bcrypt.hashpw(password, salt) return "%s$%s" % (self.algorithm, data) + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): algorithm, data = encoded.split('$', 1) assert algorithm == self.algorithm @@ -285,12 +303,14 @@ class SHA1PasswordHasher(BasePasswordHasher): """ algorithm = "sha1" + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): assert password assert salt and '$' not in salt hash = hashlib.sha1(salt + password).hexdigest() return "%s$%s$%s" % (self.algorithm, salt, hash) + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): algorithm, salt, hash = encoded.split('$', 2) assert algorithm == self.algorithm @@ -313,12 +333,14 @@ class MD5PasswordHasher(BasePasswordHasher): """ algorithm = "md5" + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): assert password assert salt and '$' not in salt hash = hashlib.md5(salt + password).hexdigest() return "%s$%s$%s" % (self.algorithm, salt, hash) + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): algorithm, salt, hash = encoded.split('$', 2) assert algorithm == self.algorithm @@ -349,11 +371,13 @@ class UnsaltedSHA1PasswordHasher(BasePasswordHasher): def salt(self): return '' + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): assert salt == '' hash = hashlib.sha1(password).hexdigest() return 'sha1$$%s' % hash + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): encoded_2 = self.encode(password, '') return constant_time_compare(encoded, encoded_2) @@ -383,10 +407,12 @@ class UnsaltedMD5PasswordHasher(BasePasswordHasher): def salt(self): return '' + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): assert salt == '' return hashlib.md5(password).hexdigest() + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): if len(encoded) == 37 and encoded.startswith('md5$$'): encoded = encoded[5:] @@ -412,6 +438,7 @@ class CryptPasswordHasher(BasePasswordHasher): def salt(self): return get_random_string(2) + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): crypt = self._load_library() assert len(salt) == 2 @@ -419,6 +446,7 @@ def encode(self, password, salt): # we don't need to store the salt, but Django used to do this return "%s$%s$%s" % (self.algorithm, '', data) + @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): crypt = self._load_library() algorithm, salt, data = encoded.split('$', 2) @@ -433,4 +461,3 @@ def safe_summary(self, encoded): (_('salt'), salt), (_('hash'), mask_hash(data, show=3)), ]) - diff --git a/django/contrib/auth/tests/hashers.py b/django/contrib/auth/tests/hashers.py index bf68c45a1b32..d1d834c66249 100644 --- a/django/contrib/auth/tests/hashers.py +++ b/django/contrib/auth/tests/hashers.py @@ -1,7 +1,8 @@ from django.conf.global_settings import PASSWORD_HASHERS as default_hashers from django.contrib.auth.hashers import (is_password_usable, check_password, make_password, PBKDF2PasswordHasher, load_hashers, - PBKDF2SHA1PasswordHasher, get_hasher, UNUSABLE_PASSWORD) + PBKDF2SHA1PasswordHasher, get_hasher, UNUSABLE_PASSWORD, + MAXIMUM_PASSWORD_LENGTH, password_max_length) from django.utils import unittest from django.utils.unittest import skipUnless from django.test.utils import override_settings @@ -28,6 +29,12 @@ def test_simple(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) + # Long password + self.assertRaises( + ValueError, + make_password, + b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + ) def test_pkbdf2(self): encoded = make_password('letmein', 'seasalt', 'pbkdf2_sha256') @@ -36,6 +43,14 @@ def test_pkbdf2(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) + # Long password + self.assertRaises( + ValueError, + make_password, + b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "seasalt", + "pbkdf2_sha256", + ) def test_sha1(self): encoded = make_password('letmein', 'seasalt', 'sha1') @@ -44,6 +59,14 @@ def test_sha1(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) + # Long password + self.assertRaises( + ValueError, + make_password, + b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "seasalt", + "sha1", + ) def test_md5(self): encoded = make_password('letmein', 'seasalt', 'md5') @@ -52,6 +75,14 @@ def test_md5(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) + # Long password + self.assertRaises( + ValueError, + make_password, + b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "seasalt", + "md5", + ) def test_unsalted_md5(self): encoded = make_password('letmein', '', 'unsalted_md5') @@ -64,6 +95,14 @@ def test_unsalted_md5(self): self.assertTrue(is_password_usable(alt_encoded)) self.assertTrue(check_password(u'letmein', alt_encoded)) self.assertFalse(check_password('letmeinz', alt_encoded)) + # Long password + self.assertRaises( + ValueError, + make_password, + b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "", + "unsalted_md5", + ) def test_unsalted_sha1(self): encoded = make_password('letmein', '', 'unsalted_sha1') @@ -74,6 +113,14 @@ def test_unsalted_sha1(self): # Raw SHA1 isn't acceptable alt_encoded = encoded[6:] self.assertRaises(ValueError, check_password, 'letmein', alt_encoded) + # Long password + self.assertRaises( + ValueError, + make_password, + b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "", + "unslated_sha1", + ) @skipUnless(crypt, "no crypt module to generate password.") def test_crypt(self): @@ -82,6 +129,14 @@ def test_crypt(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) + # Long password + self.assertRaises( + ValueError, + make_password, + b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "seasalt", + "crypt", + ) @skipUnless(bcrypt, "py-bcrypt not installed") def test_bcrypt(self): @@ -90,6 +145,13 @@ def test_bcrypt(self): self.assertTrue(encoded.startswith('bcrypt$')) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) + # Long password + self.assertRaises( + ValueError, + make_password, + b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + hasher="bcrypt", + ) def test_unusable(self): encoded = make_password(None) @@ -105,6 +167,14 @@ def doit(): make_password('letmein', hasher='lolcat') self.assertRaises(ValueError, doit) + def test_max_password_length_decorator(self): + @password_max_length(10) + def encode(s, password, salt): + return True + + self.assertTrue(encode(None, b"1234", b"1234")) + self.assertRaises(ValueError, encode, None, b"1234567890A", b"1234") + def test_low_level_pkbdf2(self): hasher = PBKDF2PasswordHasher() encoded = hasher.encode('letmein', 'seasalt') From 3ffc7b52f8704443ef0c20f34bb50c9144898ef7 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Sat, 14 Sep 2013 23:53:07 -0600 Subject: [PATCH 246/367] [1.4.x] Add release notes and bump version numbers for 1.4.8 security release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- docs/releases/1.4.8.txt | 21 +++++++++++++++++++++ setup.py | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 docs/releases/1.4.8.txt diff --git a/django/__init__.py b/django/__init__.py index 143dc69568fa..03a43fcc7454 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 8, 'alpha', 0) +VERSION = (1, 4, 8, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index fde4a58d9aaa..46db36160e7f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.7' +version = '1.4.8' # The full version, including alpha/beta/rc tags. -release = '1.4.7' +release = '1.4.8' # The next version to be released django_next_version = '1.5' diff --git a/docs/releases/1.4.8.txt b/docs/releases/1.4.8.txt new file mode 100644 index 000000000000..bec5a4b7dcb8 --- /dev/null +++ b/docs/releases/1.4.8.txt @@ -0,0 +1,21 @@ +========================== +Django 1.4.7 release notes +========================== + +*September 14, 2013* + +Django 1.4.8 fixes one security issue present in previous Django releases in +the 1.4 series. + +Denial-of-service via password hashers +-------------------------------------- + +In previous versions of Django no limit was imposed on the plaintext +length of a password. This allows a denial-of-service attack through +submission of bogus but extremely large passwords, tying up server +resources performing the (expensive, and increasingly expensive with +the length of the password) calculation of the corresponding hash. + +As of 1.4.8, Django's authentication framework imposes a 4096-byte +limit on passwords, and will fail authentication with any submitted +password of greater length. diff --git a/setup.py b/setup.py index 91fe94a16a19..ddab44f4c627 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.7.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.8.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 6903d1690a92aa040adfb0c8eb37cf62e4206714 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 15 Sep 2013 14:02:38 +0800 Subject: [PATCH 247/367] [1.4.x] Removed usage of b"" string syntax for Python 2.5 compatibility. Refs commit 3f3d887a6844ec2db743fee64c9e53e04d39a368. --- django/contrib/auth/tests/hashers.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/django/contrib/auth/tests/hashers.py b/django/contrib/auth/tests/hashers.py index d1d834c66249..93d4f4decb56 100644 --- a/django/contrib/auth/tests/hashers.py +++ b/django/contrib/auth/tests/hashers.py @@ -33,7 +33,7 @@ def test_simple(self): self.assertRaises( ValueError, make_password, - b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "1" * (MAXIMUM_PASSWORD_LENGTH + 1), ) def test_pkbdf2(self): @@ -47,7 +47,7 @@ def test_pkbdf2(self): self.assertRaises( ValueError, make_password, - b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "1" * (MAXIMUM_PASSWORD_LENGTH + 1), "seasalt", "pbkdf2_sha256", ) @@ -63,7 +63,7 @@ def test_sha1(self): self.assertRaises( ValueError, make_password, - b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "1" * (MAXIMUM_PASSWORD_LENGTH + 1), "seasalt", "sha1", ) @@ -79,7 +79,7 @@ def test_md5(self): self.assertRaises( ValueError, make_password, - b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "1" * (MAXIMUM_PASSWORD_LENGTH + 1), "seasalt", "md5", ) @@ -99,7 +99,7 @@ def test_unsalted_md5(self): self.assertRaises( ValueError, make_password, - b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "1" * (MAXIMUM_PASSWORD_LENGTH + 1), "", "unsalted_md5", ) @@ -117,7 +117,7 @@ def test_unsalted_sha1(self): self.assertRaises( ValueError, make_password, - b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "1" * (MAXIMUM_PASSWORD_LENGTH + 1), "", "unslated_sha1", ) @@ -133,7 +133,7 @@ def test_crypt(self): self.assertRaises( ValueError, make_password, - b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "1" * (MAXIMUM_PASSWORD_LENGTH + 1), "seasalt", "crypt", ) @@ -149,7 +149,7 @@ def test_bcrypt(self): self.assertRaises( ValueError, make_password, - b"1" * (MAXIMUM_PASSWORD_LENGTH + 1), + "1" * (MAXIMUM_PASSWORD_LENGTH + 1), hasher="bcrypt", ) @@ -172,8 +172,8 @@ def test_max_password_length_decorator(self): def encode(s, password, salt): return True - self.assertTrue(encode(None, b"1234", b"1234")) - self.assertRaises(ValueError, encode, None, b"1234567890A", b"1234") + self.assertTrue(encode(None, "1234", "1234")) + self.assertRaises(ValueError, encode, None, "1234567890A", "1234") def test_low_level_pkbdf2(self): hasher = PBKDF2PasswordHasher() From 629813a8041da266d7ea1a001cd46259f87a486a Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Fri, 15 Feb 2013 20:11:46 +0100 Subject: [PATCH 248/367] [1.4.x] Fixed geos test to prevent random failure Points in the test fixtures have 20 as max coordinate. Backport of 87854b0bdf354059f949350a4d63a0ed071d564c from master. --- django/contrib/gis/geos/tests/test_geos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/contrib/gis/geos/tests/test_geos.py b/django/contrib/gis/geos/tests/test_geos.py index a8372b4f699a..ddd5d582265c 100644 --- a/django/contrib/gis/geos/tests/test_geos.py +++ b/django/contrib/gis/geos/tests/test_geos.py @@ -662,7 +662,7 @@ def test16_mutable_geometries(self): for i in range(len(mp)): # Creating a random point. pnt = mp[i] - new = Point(random.randint(1, 100), random.randint(1, 100)) + new = Point(random.randint(21, 100), random.randint(21, 100)) # Testing the assignment mp[i] = new s = str(new) # what was used for the assignment is still accessible From efee30e6b0ea5c7bbfd4adb84c186f92edf49c13 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 15 Sep 2013 12:59:10 -0400 Subject: [PATCH 249/367] [1.4.x] Bump version post-release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 03a43fcc7454..d9d6c4bac44d 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 8, 'final', 0) +VERSION = (1, 4, 9, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From ca77e38d243c5f1f1a5070cba0988d230d0bb050 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 15 Sep 2013 14:14:26 -0400 Subject: [PATCH 250/367] [1.4.x] Cleaned up 1.4.8 release notes Backport of 8d29005524 from master --- docs/howto/error-reporting.txt | 6 +++++- docs/releases/1.4-alpha-1.txt | 7 ++++--- docs/releases/1.4-beta-1.txt | 7 ++++--- docs/releases/1.4.8.txt | 21 ++++++++++++++++----- docs/releases/1.4.txt | 9 +++++---- docs/releases/index.txt | 1 + 6 files changed, 35 insertions(+), 16 deletions(-) diff --git a/docs/howto/error-reporting.txt b/docs/howto/error-reporting.txt index 64af2a09803b..5314821f4935 100644 --- a/docs/howto/error-reporting.txt +++ b/docs/howto/error-reporting.txt @@ -123,6 +123,8 @@ Filtering error reports Filtering sensitive information ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. currentmodule:: django.views.decorators.debug + Error reports are really helpful for debugging errors, so it is generally useful to record as much relevant information about those errors as possible. For example, by default Django records the `full traceback`_ for the @@ -236,11 +238,13 @@ attribute:: request.exception_reporter_filter = CustomExceptionReporterFilter() ... +.. currentmodule:: django.views.debug + Your custom filter class needs to inherit from :class:`django.views.debug.SafeExceptionReporterFilter` and may override the following methods: -.. class:: django.views.debug.SafeExceptionReporterFilter +.. class:: SafeExceptionReporterFilter .. method:: SafeExceptionReporterFilter.is_active(self, request) diff --git a/docs/releases/1.4-alpha-1.txt b/docs/releases/1.4-alpha-1.txt index b5ec782f09eb..5f786446798f 100644 --- a/docs/releases/1.4-alpha-1.txt +++ b/docs/releases/1.4-alpha-1.txt @@ -337,9 +337,10 @@ docs ` for more information. Error report filtering ~~~~~~~~~~~~~~~~~~~~~~ -Two new function decorators, :func:`sensitive_variables` and -:func:`sensitive_post_parameters`, were added to allow designating the -local variables and POST parameters which may contain sensitive +We added two function decorators, +:func:`~django.views.decorators.debug.sensitive_variables` and +:func:`~django.views.decorators.debug.sensitive_post_parameters`, to allow +designating the local variables and POST parameters that may contain sensitive information and should be filtered out of error reports. All POST parameters are now systematically filtered out of error reports for diff --git a/docs/releases/1.4-beta-1.txt b/docs/releases/1.4-beta-1.txt index 88f32ea15fea..ee2c2e9fec79 100644 --- a/docs/releases/1.4-beta-1.txt +++ b/docs/releases/1.4-beta-1.txt @@ -375,9 +375,10 @@ docs ` for more information. Error report filtering ~~~~~~~~~~~~~~~~~~~~~~ -Two new function decorators, :func:`sensitive_variables` and -:func:`sensitive_post_parameters`, were added to allow designating the -local variables and POST parameters which may contain sensitive +We added two function decorators, +:func:`~django.views.decorators.debug.sensitive_variables` and +:func:`~django.views.decorators.debug.sensitive_post_parameters`, to allow +designating the local variables and POST parameters that may contain sensitive information and should be filtered out of error reports. All POST parameters are now systematically filtered out of error reports for diff --git a/docs/releases/1.4.8.txt b/docs/releases/1.4.8.txt index bec5a4b7dcb8..08dca4065edb 100644 --- a/docs/releases/1.4.8.txt +++ b/docs/releases/1.4.8.txt @@ -1,21 +1,32 @@ ========================== -Django 1.4.7 release notes +Django 1.4.8 release notes ========================== *September 14, 2013* -Django 1.4.8 fixes one security issue present in previous Django releases in +Django 1.4.8 fixes two security issues present in previous Django releases in the 1.4 series. Denial-of-service via password hashers -------------------------------------- -In previous versions of Django no limit was imposed on the plaintext -length of a password. This allows a denial-of-service attack through +In previous versions of Django, no limit was imposed on the plaintext +length of a password. This allowed a denial-of-service attack through submission of bogus but extremely large passwords, tying up server resources performing the (expensive, and increasingly expensive with the length of the password) calculation of the corresponding hash. As of 1.4.8, Django's authentication framework imposes a 4096-byte -limit on passwords, and will fail authentication with any submitted +limit on passwords and will fail authentication with any submitted password of greater length. + +Corrected usage of :func:`~django.views.decorators.debug.sensitive_post_parameters` in :mod:`django.contrib.auth`’s admin +------------------------------------------------------------------------------------------------------------------------- + +The decoration of the ``add_view`` and ``user_change_password`` user admin +views with :func:`~django.views.decorators.debug.sensitive_post_parameters` +did not include :func:`~django.utils.decorators.method_decorator` (required +since the views are methods) resulting in the decorator not being properly +applied. This usage has been fixed and +:func:`~django.views.decorators.debug.sensitive_post_parameters` will now +throw an exception if it's improperly used. diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index a0918696459d..2374eb60c78f 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -507,10 +507,11 @@ docs ` for more information. Error report filtering ~~~~~~~~~~~~~~~~~~~~~~ -We added two function decorators, :func:`sensitive_variables` and -:func:`sensitive_post_parameters`, to allow designating the local variables -and POST parameters that may contain sensitive information and should be -filtered out of error reports. +We added two function decorators, +:func:`~django.views.decorators.debug.sensitive_variables` and +:func:`~django.views.decorators.debug.sensitive_post_parameters`, to allow +designating the local variables and POST parameters that may contain sensitive +information and should be filtered out of error reports. All POST parameters are now systematically filtered out of error reports for certain views (``login``, ``password_reset_confirm``, ``password_change`` and diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 0a4198bcb55a..56b1155fac9c 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -20,6 +20,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.8 1.4.7 1.4.6 1.4.5 From 0317edf0c7779902d49c6efb8242af61e5569cde Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Tue, 24 Sep 2013 21:19:20 +0200 Subject: [PATCH 251/367] Revert "[1.4.x] Ensure that passwords are never long enough for a DoS." This reverts commit 3f3d887a6844ec2db743fee64c9e53e04d39a368. This fix is no longer necessary, our pbkdf2 (see next commit) implementation no longer rehashes the password every iteration. --- django/contrib/auth/forms.py | 51 ++++++-------------- django/contrib/auth/hashers.py | 29 +---------- django/contrib/auth/tests/hashers.py | 72 +--------------------------- 3 files changed, 16 insertions(+), 136 deletions(-) diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index 2d1c9bb761f3..f0ef124b204e 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -8,10 +8,7 @@ from django.contrib.auth import authenticate from django.contrib.auth.models import User -from django.contrib.auth.hashers import ( - MAXIMUM_PASSWORD_LENGTH, UNUSABLE_PASSWORD, - is_password_usable, get_hasher -) +from django.contrib.auth.hashers import UNUSABLE_PASSWORD, is_password_usable, get_hasher from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.models import get_current_site @@ -73,11 +70,10 @@ class UserCreationForm(forms.ModelForm): 'invalid': _("This value may contain only letters, numbers and " "@/./+/-/_ characters.")}) password1 = forms.CharField(label=_("Password"), - widget=forms.PasswordInput, max_length=MAXIMUM_PASSWORD_LENGTH) + widget=forms.PasswordInput) password2 = forms.CharField(label=_("Password confirmation"), widget=forms.PasswordInput, - max_length=MAXIMUM_PASSWORD_LENGTH, - help_text=_("Enter the same password as above, for verification.")) + help_text = _("Enter the same password as above, for verification.")) class Meta: model = User @@ -141,11 +137,7 @@ class AuthenticationForm(forms.Form): username/password logins. """ username = forms.CharField(label=_("Username"), max_length=30) - password = forms.CharField( - label=_("Password"), - widget=forms.PasswordInput, - max_length=MAXIMUM_PASSWORD_LENGTH, - ) + password = forms.CharField(label=_("Password"), widget=forms.PasswordInput) error_messages = { 'invalid_login': _("Please enter a correct username and password. " @@ -258,16 +250,10 @@ class SetPasswordForm(forms.Form): error_messages = { 'password_mismatch': _("The two password fields didn't match."), } - new_password1 = forms.CharField( - label=_("New password"), - widget=forms.PasswordInput, - max_length=MAXIMUM_PASSWORD_LENGTH, - ) - new_password2 = forms.CharField( - label=_("New password confirmation"), - widget=forms.PasswordInput, - max_length=MAXIMUM_PASSWORD_LENGTH, - ) + new_password1 = forms.CharField(label=_("New password"), + widget=forms.PasswordInput) + new_password2 = forms.CharField(label=_("New password confirmation"), + widget=forms.PasswordInput) def __init__(self, user, *args, **kwargs): self.user = user @@ -298,11 +284,8 @@ class PasswordChangeForm(SetPasswordForm): 'password_incorrect': _("Your old password was entered incorrectly. " "Please enter it again."), }) - old_password = forms.CharField( - label=_("Old password"), - widget=forms.PasswordInput, - max_length=MAXIMUM_PASSWORD_LENGTH, - ) + old_password = forms.CharField(label=_("Old password"), + widget=forms.PasswordInput) def clean_old_password(self): """ @@ -324,16 +307,10 @@ class AdminPasswordChangeForm(forms.Form): error_messages = { 'password_mismatch': _("The two password fields didn't match."), } - password1 = forms.CharField( - label=_("Password"), - widget=forms.PasswordInput, - max_length=MAXIMUM_PASSWORD_LENGTH, - ) - password2 = forms.CharField( - label=_("Password (again)"), - widget=forms.PasswordInput, - max_length=MAXIMUM_PASSWORD_LENGTH, - ) + password1 = forms.CharField(label=_("Password"), + widget=forms.PasswordInput) + password2 = forms.CharField(label=_("Password (again)"), + widget=forms.PasswordInput) def __init__(self, user, *args, **kwargs): self.user = user diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py index 2d7e7bd9c0f4..a9dbcc9568e1 100644 --- a/django/contrib/auth/hashers.py +++ b/django/contrib/auth/hashers.py @@ -1,4 +1,3 @@ -import functools import hashlib from django.conf import settings @@ -12,23 +11,10 @@ UNUSABLE_PASSWORD = '!' # This will never be a valid encoded hash -MAXIMUM_PASSWORD_LENGTH = 4096 # The maximum length a password can be to prevent DoS HASHERS = None # lazily loaded from PASSWORD_HASHERS PREFERRED_HASHER = None # defaults to first item in PASSWORD_HASHERS -def password_max_length(max_length): - def inner(fn): - @functools.wraps(fn) - def wrapper(self, password, *args, **kwargs): - if len(password) > max_length: - raise ValueError("Invalid password; Must be less than or equal" - " to %d bytes" % max_length) - return fn(self, password, *args, **kwargs) - return wrapper - return inner - - def is_password_usable(encoded): return (encoded is not None and encoded != UNUSABLE_PASSWORD) @@ -216,7 +202,6 @@ class PBKDF2PasswordHasher(BasePasswordHasher): iterations = 10000 digest = hashlib.sha256 - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt, iterations=None): assert password assert salt and '$' not in salt @@ -226,7 +211,6 @@ def encode(self, password, salt, iterations=None): hash = hash.encode('base64').strip() return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash) - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): algorithm, iterations, salt, hash = encoded.split('$', 3) assert algorithm == self.algorithm @@ -272,13 +256,11 @@ def salt(self): bcrypt = self._load_library() return bcrypt.gensalt(self.rounds) - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): bcrypt = self._load_library() data = bcrypt.hashpw(password, salt) return "%s$%s" % (self.algorithm, data) - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): algorithm, data = encoded.split('$', 1) assert algorithm == self.algorithm @@ -303,14 +285,12 @@ class SHA1PasswordHasher(BasePasswordHasher): """ algorithm = "sha1" - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): assert password assert salt and '$' not in salt hash = hashlib.sha1(salt + password).hexdigest() return "%s$%s$%s" % (self.algorithm, salt, hash) - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): algorithm, salt, hash = encoded.split('$', 2) assert algorithm == self.algorithm @@ -333,14 +313,12 @@ class MD5PasswordHasher(BasePasswordHasher): """ algorithm = "md5" - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): assert password assert salt and '$' not in salt hash = hashlib.md5(salt + password).hexdigest() return "%s$%s$%s" % (self.algorithm, salt, hash) - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): algorithm, salt, hash = encoded.split('$', 2) assert algorithm == self.algorithm @@ -371,13 +349,11 @@ class UnsaltedSHA1PasswordHasher(BasePasswordHasher): def salt(self): return '' - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): assert salt == '' hash = hashlib.sha1(password).hexdigest() return 'sha1$$%s' % hash - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): encoded_2 = self.encode(password, '') return constant_time_compare(encoded, encoded_2) @@ -407,12 +383,10 @@ class UnsaltedMD5PasswordHasher(BasePasswordHasher): def salt(self): return '' - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): assert salt == '' return hashlib.md5(password).hexdigest() - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): if len(encoded) == 37 and encoded.startswith('md5$$'): encoded = encoded[5:] @@ -438,7 +412,6 @@ class CryptPasswordHasher(BasePasswordHasher): def salt(self): return get_random_string(2) - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def encode(self, password, salt): crypt = self._load_library() assert len(salt) == 2 @@ -446,7 +419,6 @@ def encode(self, password, salt): # we don't need to store the salt, but Django used to do this return "%s$%s$%s" % (self.algorithm, '', data) - @password_max_length(MAXIMUM_PASSWORD_LENGTH) def verify(self, password, encoded): crypt = self._load_library() algorithm, salt, data = encoded.split('$', 2) @@ -461,3 +433,4 @@ def safe_summary(self, encoded): (_('salt'), salt), (_('hash'), mask_hash(data, show=3)), ]) + diff --git a/django/contrib/auth/tests/hashers.py b/django/contrib/auth/tests/hashers.py index 93d4f4decb56..bf68c45a1b32 100644 --- a/django/contrib/auth/tests/hashers.py +++ b/django/contrib/auth/tests/hashers.py @@ -1,8 +1,7 @@ from django.conf.global_settings import PASSWORD_HASHERS as default_hashers from django.contrib.auth.hashers import (is_password_usable, check_password, make_password, PBKDF2PasswordHasher, load_hashers, - PBKDF2SHA1PasswordHasher, get_hasher, UNUSABLE_PASSWORD, - MAXIMUM_PASSWORD_LENGTH, password_max_length) + PBKDF2SHA1PasswordHasher, get_hasher, UNUSABLE_PASSWORD) from django.utils import unittest from django.utils.unittest import skipUnless from django.test.utils import override_settings @@ -29,12 +28,6 @@ def test_simple(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) - # Long password - self.assertRaises( - ValueError, - make_password, - "1" * (MAXIMUM_PASSWORD_LENGTH + 1), - ) def test_pkbdf2(self): encoded = make_password('letmein', 'seasalt', 'pbkdf2_sha256') @@ -43,14 +36,6 @@ def test_pkbdf2(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) - # Long password - self.assertRaises( - ValueError, - make_password, - "1" * (MAXIMUM_PASSWORD_LENGTH + 1), - "seasalt", - "pbkdf2_sha256", - ) def test_sha1(self): encoded = make_password('letmein', 'seasalt', 'sha1') @@ -59,14 +44,6 @@ def test_sha1(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) - # Long password - self.assertRaises( - ValueError, - make_password, - "1" * (MAXIMUM_PASSWORD_LENGTH + 1), - "seasalt", - "sha1", - ) def test_md5(self): encoded = make_password('letmein', 'seasalt', 'md5') @@ -75,14 +52,6 @@ def test_md5(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) - # Long password - self.assertRaises( - ValueError, - make_password, - "1" * (MAXIMUM_PASSWORD_LENGTH + 1), - "seasalt", - "md5", - ) def test_unsalted_md5(self): encoded = make_password('letmein', '', 'unsalted_md5') @@ -95,14 +64,6 @@ def test_unsalted_md5(self): self.assertTrue(is_password_usable(alt_encoded)) self.assertTrue(check_password(u'letmein', alt_encoded)) self.assertFalse(check_password('letmeinz', alt_encoded)) - # Long password - self.assertRaises( - ValueError, - make_password, - "1" * (MAXIMUM_PASSWORD_LENGTH + 1), - "", - "unsalted_md5", - ) def test_unsalted_sha1(self): encoded = make_password('letmein', '', 'unsalted_sha1') @@ -113,14 +74,6 @@ def test_unsalted_sha1(self): # Raw SHA1 isn't acceptable alt_encoded = encoded[6:] self.assertRaises(ValueError, check_password, 'letmein', alt_encoded) - # Long password - self.assertRaises( - ValueError, - make_password, - "1" * (MAXIMUM_PASSWORD_LENGTH + 1), - "", - "unslated_sha1", - ) @skipUnless(crypt, "no crypt module to generate password.") def test_crypt(self): @@ -129,14 +82,6 @@ def test_crypt(self): self.assertTrue(is_password_usable(encoded)) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) - # Long password - self.assertRaises( - ValueError, - make_password, - "1" * (MAXIMUM_PASSWORD_LENGTH + 1), - "seasalt", - "crypt", - ) @skipUnless(bcrypt, "py-bcrypt not installed") def test_bcrypt(self): @@ -145,13 +90,6 @@ def test_bcrypt(self): self.assertTrue(encoded.startswith('bcrypt$')) self.assertTrue(check_password(u'letmein', encoded)) self.assertFalse(check_password('letmeinz', encoded)) - # Long password - self.assertRaises( - ValueError, - make_password, - "1" * (MAXIMUM_PASSWORD_LENGTH + 1), - hasher="bcrypt", - ) def test_unusable(self): encoded = make_password(None) @@ -167,14 +105,6 @@ def doit(): make_password('letmein', hasher='lolcat') self.assertRaises(ValueError, doit) - def test_max_password_length_decorator(self): - @password_max_length(10) - def encode(s, password, salt): - return True - - self.assertTrue(encode(None, "1234", "1234")) - self.assertRaises(ValueError, encode, None, "1234567890A", "1234") - def test_low_level_pkbdf2(self): hasher = PBKDF2PasswordHasher() encoded = hasher.encode('letmein', 'seasalt') From e2403db95a494c0660ef09f94d9fca1604111be2 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Tue, 17 Sep 2013 22:59:56 +0200 Subject: [PATCH 252/367] [1.4.x] Fixed #21138 -- Increased the performance of our PBKDF2 implementation. Thanks go to Michael Gebetsroither for pointing out this issue and help on the patch. Backport of 68540fe4df44492571bc610a0a043d3d02b3d320 from master. --- django/utils/crypto.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/django/utils/crypto.py b/django/utils/crypto.py index 44b7faf492ee..5d138d0c1d5a 100644 --- a/django/utils/crypto.py +++ b/django/utils/crypto.py @@ -111,9 +111,8 @@ def _fast_hmac(key, msg, digest): A trimmed down version of Python's HMAC implementation """ dig1, dig2 = digest(), digest() - if len(key) > dig1.block_size: - key = digest(key).digest() - key += chr(0) * (dig1.block_size - len(key)) + if len(key) != dig1.block_size: + raise ValueError('Key size needs to match the block_size of the digest.') dig1.update(key.translate(_trans_36)) dig1.update(msg) dig2.update(key.translate(_trans_5c)) @@ -146,6 +145,11 @@ def pbkdf2(password, salt, iterations, dklen=0, digest=None): hex_format_string = "%%0%ix" % (hlen * 2) + inner_digest_size = digest().block_size + if len(password) > inner_digest_size: + password = digest(password).digest() + password += b'\x00' * (inner_digest_size - len(password)) + def F(i): def U(): u = salt + struct.pack('>I', i) From 037ec1054ca8c08e65307f53e5851fe50ac5e8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anssi=20K=C3=A4=C3=A4ri=C3=A4inen?= Date: Wed, 9 Oct 2013 14:20:37 +0300 Subject: [PATCH 253/367] [1.4.x] Fixed #21248 -- Skipped test_bcrypt if no py-bcrypt found Pre 1.6 Django worked only with py-bcrypt, not with bcrypt. Skipped test_bcrypt when using bcrypt to avoid false positives. Backpatch of 9f8a36eb20895d9e542820d5190bfa77ad1b85d9 from stable/1.5.x. --- django/contrib/auth/tests/hashers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/django/contrib/auth/tests/hashers.py b/django/contrib/auth/tests/hashers.py index bf68c45a1b32..cf383e3da1b6 100644 --- a/django/contrib/auth/tests/hashers.py +++ b/django/contrib/auth/tests/hashers.py @@ -14,6 +14,10 @@ try: import bcrypt + # Django 1.4 works only with py-bcrypt, not with bcrypt. py-bcrypt has + # '_bcrypt' attribute, bcrypt doesn't. + if not hasattr(bcrypt, '_bcrypt'): + bcrypt = None except ImportError: bcrypt = None From ea04c81d372aaa1b1aebdacefc46735ef9559fb6 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Sun, 13 Oct 2013 19:09:26 +0200 Subject: [PATCH 254/367] [1.4.x] Fixed #21256 -- Error in datetime_safe.datetime.combine. Backport of d9b6fb8 from master --- django/utils/datetime_safe.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/django/utils/datetime_safe.py b/django/utils/datetime_safe.py index b634888092f5..ca96fb37b047 100644 --- a/django/utils/datetime_safe.py +++ b/django/utils/datetime_safe.py @@ -19,8 +19,11 @@ class datetime(real_datetime): def strftime(self, fmt): return strftime(self, fmt) - def combine(self, date, time): - return datetime(date.year, date.month, date.day, time.hour, time.minute, time.microsecond, time.tzinfo) + @classmethod + def combine(cls, date, time): + return cls(date.year, date.month, date.day, + time.hour, time.minute, time.second, + time.microsecond, time.tzinfo) def date(self): return date(self.year, self.month, self.day) From c4f29c91f9ad0fc3982f785f21093ba624ead8f8 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Tue, 17 Sep 2013 18:14:38 -0500 Subject: [PATCH 255/367] [1.4.x] Fixed #21253 -- PBKDF2 with cached HMAC key This gives a 2x speed increase compared to the existing implementation. Thanks to Steve Thomas for the initial patch and Tim Graham for finishing it. Backport of 1e4f53a6eb8d1816e51eb8bd8f95e704f6b89ead from master. --- django/utils/crypto.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/django/utils/crypto.py b/django/utils/crypto.py index 5d138d0c1d5a..e07ef6c1efef 100644 --- a/django/utils/crypto.py +++ b/django/utils/crypto.py @@ -106,20 +106,6 @@ def _long_to_bin(x, hex_format_string): return binascii.unhexlify(hex_format_string % x) -def _fast_hmac(key, msg, digest): - """ - A trimmed down version of Python's HMAC implementation - """ - dig1, dig2 = digest(), digest() - if len(key) != dig1.block_size: - raise ValueError('Key size needs to match the block_size of the digest.') - dig1.update(key.translate(_trans_36)) - dig1.update(msg) - dig2.update(key.translate(_trans_5c)) - dig2.update(dig1.digest()) - return dig2 - - def pbkdf2(password, salt, iterations, dklen=0, digest=None): """ Implements PBKDF2 as defined in RFC 2898, section 5.2 @@ -145,16 +131,21 @@ def pbkdf2(password, salt, iterations, dklen=0, digest=None): hex_format_string = "%%0%ix" % (hlen * 2) - inner_digest_size = digest().block_size - if len(password) > inner_digest_size: + inner, outer = digest(), digest() + if len(password) > inner.block_size: password = digest(password).digest() - password += b'\x00' * (inner_digest_size - len(password)) + password += b'\x00' * (inner.block_size - len(password)) + inner.update(password.translate(hmac.trans_36)) + outer.update(password.translate(hmac.trans_5C)) def F(i): def U(): u = salt + struct.pack('>I', i) for j in xrange(int(iterations)): - u = _fast_hmac(password, u, digest).digest() + dig1, dig2 = inner.copy(), outer.copy() + dig1.update(u) + dig2.update(dig1.digest()) + u = dig2.digest() yield _bin_to_long(u) return _long_to_bin(reduce(operator.xor, U()), hex_format_string) From ead7c496a4bdd0eb8e2282ce982e1292846e7c91 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 25 Sep 2013 09:33:29 -0400 Subject: [PATCH 256/367] [1.4.x] Added 1.4.9 release notes Backport of 2eb8f15516 from master --- docs/releases/1.4.9.txt | 21 +++++++++++++++++++++ docs/releases/index.txt | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 docs/releases/1.4.9.txt diff --git a/docs/releases/1.4.9.txt b/docs/releases/1.4.9.txt new file mode 100644 index 000000000000..de66eb78f89e --- /dev/null +++ b/docs/releases/1.4.9.txt @@ -0,0 +1,21 @@ +========================== +Django 1.4.9 release notes +========================== + +*October 22, 2013* + +Django 1.4.9 fixes a security-related bug in the 1.4 series and one other +data corruption bug. + +Readdressed denial-of-service via password hashers +-------------------------------------------------- + +Django 1.4.8 imposes a 4096-byte limit on passwords in order to mitigate a +denial-of-service attack through submission of bogus but extremely large +passwords. In Django 1.5.5, we've reverted this change and instead improved +the speed of our PBKDF2 algorithm by not rehashing the key on every iteration. + +Bugfixes +======== + +* Fixed a data corruption bug with ``datetime_safe.datetime.combine`` (#21256). diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 56b1155fac9c..4673b5a30253 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -14,12 +14,12 @@ up to and including the new version. Final releases ============== - 1.4 release ----------- .. toctree:: :maxdepth: 1 + 1.4.9 1.4.8 1.4.7 1.4.6 From 6de3726423b8df4fb8ab05caa109a8c6308e6060 Mon Sep 17 00:00:00 2001 From: Shai Berger Date: Mon, 21 Oct 2013 18:12:48 +0300 Subject: [PATCH 257/367] Fixed #13245: Explained Oracle's behavior w.r.t db_table and how to prevent table-name truncation Thanks russellm & timo for discussion, and timo for review. Backported from master 317040a73b77be8f8210801793b2ce6d1a69301e --- docs/ref/databases.txt | 16 ++++++++++++++++ docs/ref/models/options.txt | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index dba278bc4fc1..4c18658304b5 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -705,6 +705,22 @@ Naming issues Oracle imposes a name length limit of 30 characters. To accommodate this, the backend truncates database identifiers to fit, replacing the final four characters of the truncated name with a repeatable MD5 hash value. +Additionally, the backend turns database identifiers to all-uppercase. + +To prevent these transformations (this is usually required only when dealing +with legacy databases or accessing tables which belong to other users), use +a quoted name as the value for ``db_table``:: + + class LegacyModel(models.Model): + class Meta: + db_table = '"name_left_in_lowercase"' + + class ForeignModel(models.Model): + class Meta: + db_table = '"OTHER_USER"."NAME_ONLY_SEEMS_OVER_30"' + +Quoted names can also be used with Django's other supported database +backends; except for Oracle, however, the quotes have no effect. When running syncdb, an ``ORA-06552`` error may be encountered if certain Oracle keywords are used as the name of a model field or the diff --git a/docs/ref/models/options.txt b/docs/ref/models/options.txt index 9d076f6274a5..9767ea5f1b6c 100644 --- a/docs/ref/models/options.txt +++ b/docs/ref/models/options.txt @@ -67,6 +67,18 @@ Django quotes column and table names behind the scenes. the table name via ``db_table``, particularly if you are using the MySQL backend. See the :ref:`MySQL notes ` for more details. +.. admonition:: Table name quoting for Oracle + + In order to to meet the 30-char limitation Oracle has on table names, + and match the usual conventions for Oracle databases, Django may shorten + table names and turn them all-uppercase. To prevent such transformations, + use a quoted name as the value for ``db_table``:: + + db_table = '"name_left_in_lowercase"' + + Such quoted names can also be used with Django's other supported database + backends; except for Oracle, however, the quotes have no effect. See the + :ref:`Oracle notes ` for more details. ``db_tablespace`` ----------------- From 3a46f621fe376c9fac59fd980b9c58100bcdf47c Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 23 Oct 2013 18:28:41 -0400 Subject: [PATCH 258/367] [1.4.x] Bumped release date for 1.5.5 & 1.4.9. Backport of 4ce5c119b5 from master --- docs/releases/1.4.9.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.4.9.txt b/docs/releases/1.4.9.txt index de66eb78f89e..e84e8a13cf19 100644 --- a/docs/releases/1.4.9.txt +++ b/docs/releases/1.4.9.txt @@ -2,7 +2,7 @@ Django 1.4.9 release notes ========================== -*October 22, 2013* +*October 23, 2013* Django 1.4.9 fixes a security-related bug in the 1.4 series and one other data corruption bug. From 8f36d1fd952f5bc2066a8d33721025351bf2b4ce Mon Sep 17 00:00:00 2001 From: James Bennett Date: Thu, 24 Oct 2013 23:37:26 -0500 Subject: [PATCH 259/367] [1.4.x] Bump everything for 1.4.9 bugfix release. --- django/__init__.py | 2 +- docs/conf.py | 2 +- docs/releases/1.4.9.txt | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index d9d6c4bac44d..0a5cbab70f47 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 9, 'alpha', 0) +VERSION = (1, 4, 9, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 46db36160e7f..cc01014dffaa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,7 +52,7 @@ # The short X.Y version. version = '1.4.8' # The full version, including alpha/beta/rc tags. -release = '1.4.8' +release = '1.4.9' # The next version to be released django_next_version = '1.5' diff --git a/docs/releases/1.4.9.txt b/docs/releases/1.4.9.txt index e84e8a13cf19..da12c1a451f1 100644 --- a/docs/releases/1.4.9.txt +++ b/docs/releases/1.4.9.txt @@ -2,7 +2,7 @@ Django 1.4.9 release notes ========================== -*October 23, 2013* +*October 24, 2013* Django 1.4.9 fixes a security-related bug in the 1.4 series and one other data corruption bug. diff --git a/setup.py b/setup.py index ddab44f4c627..d0403af5b8cb 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.8.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.9.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 11b750b031a1aabc9b0300e693d5d6c13bd1907f Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 25 Oct 2013 07:54:10 -0400 Subject: [PATCH 260/367] [1.4.x] Bump version post-release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 0a5cbab70f47..7b149e61ff3e 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 9, 'final', 0) +VERSION = (1, 4, 10, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From d491702ed7ef704a204d358024590e5f7c2e3c47 Mon Sep 17 00:00:00 2001 From: Paolo Melchiorre Date: Thu, 24 Oct 2013 11:11:52 +0200 Subject: [PATCH 261/367] [1.4.x] Fixed typo in docs/releases/1.4.9.txt. Backport of 3b0293370a from master --- docs/releases/1.4.9.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.4.9.txt b/docs/releases/1.4.9.txt index da12c1a451f1..d7d79e710789 100644 --- a/docs/releases/1.4.9.txt +++ b/docs/releases/1.4.9.txt @@ -12,7 +12,7 @@ Readdressed denial-of-service via password hashers Django 1.4.8 imposes a 4096-byte limit on passwords in order to mitigate a denial-of-service attack through submission of bogus but extremely large -passwords. In Django 1.5.5, we've reverted this change and instead improved +passwords. In Django 1.4.9, we've reverted this change and instead improved the speed of our PBKDF2 algorithm by not rehashing the key on every iteration. Bugfixes From 7984b58e78f96541e48e7772be62afe2847aca24 Mon Sep 17 00:00:00 2001 From: Loic Bistuer Date: Fri, 1 Nov 2013 03:35:29 +0700 Subject: [PATCH 262/367] Fixed SyntaxError on Python 2.5 caused by a @unittest.skipIf class decoration. --- tests/regressiontests/templates/tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/regressiontests/templates/tests.py b/tests/regressiontests/templates/tests.py index d3defb81832c..8565d192890c 100644 --- a/tests/regressiontests/templates/tests.py +++ b/tests/regressiontests/templates/tests.py @@ -1765,9 +1765,7 @@ def test_include_only(self): 'none' ) - -@unittest.skipIf(' ' in __file__, - "The {%% ssi %%} tag in Django 1.4 doesn't support spaces in path.") +skip_reason = "The {%% ssi %%} tag in Django 1.4 doesn't support spaces in path." class SSITests(unittest.TestCase): def setUp(self): self.this_dir = os.path.dirname(os.path.abspath(__file__)) @@ -1778,11 +1776,13 @@ def render_ssi(self, path): self.assertTrue(os.path.exists(path)) return template.Template('{%% ssi %s %%}' % path).render(Context()) + @unittest.skipIf(' ' in __file__, skip_reason) def test_allowed_paths(self): acceptable_path = os.path.join(self.ssi_dir, "..", "first", "test.html") with override_settings(ALLOWED_INCLUDE_ROOTS=(self.ssi_dir,)): self.assertEqual(self.render_ssi(acceptable_path), 'First template\n') + @unittest.skipIf(' ' in __file__, skip_reason) def test_relative_include_exploit(self): """ May not bypass ALLOWED_INCLUDE_ROOTS with relative paths From 848a7594748c0124a208425dbf0fd2cc861995c3 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Sat, 2 Nov 2013 18:18:18 +0100 Subject: [PATCH 263/367] Fixed #21362 -- Restored Python 2.5 compatibility. --- django/utils/crypto.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/django/utils/crypto.py b/django/utils/crypto.py index e07ef6c1efef..fb3faeb77967 100644 --- a/django/utils/crypto.py +++ b/django/utils/crypto.py @@ -134,9 +134,9 @@ def pbkdf2(password, salt, iterations, dklen=0, digest=None): inner, outer = digest(), digest() if len(password) > inner.block_size: password = digest(password).digest() - password += b'\x00' * (inner.block_size - len(password)) - inner.update(password.translate(hmac.trans_36)) - outer.update(password.translate(hmac.trans_5C)) + password += '\x00' * (inner.block_size - len(password)) + inner.update(password.translate(_trans_36)) + outer.update(password.translate(_trans_5c)) def F(i): def U(): From 30eb916bdb9b6b9fc881dfda919b49d036953a3b Mon Sep 17 00:00:00 2001 From: James Bennett Date: Wed, 6 Nov 2013 08:17:26 -0600 Subject: [PATCH 264/367] [1.4.x] Bump version info and add release notes for 1.4.10. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- docs/releases/1.4.10.txt | 12 ++++++++++++ setup.py | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 docs/releases/1.4.10.txt diff --git a/django/__init__.py b/django/__init__.py index 7b149e61ff3e..6ec73911af02 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 10, 'alpha', 0) +VERSION = (1, 4, 10, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index cc01014dffaa..6e25feebb79e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.8' +version = '1.4.10' # The full version, including alpha/beta/rc tags. -release = '1.4.9' +release = '1.4.10' # The next version to be released django_next_version = '1.5' diff --git a/docs/releases/1.4.10.txt b/docs/releases/1.4.10.txt new file mode 100644 index 000000000000..59d1646d0b07 --- /dev/null +++ b/docs/releases/1.4.10.txt @@ -0,0 +1,12 @@ +============================ + Django 1.4.10 release notes +============================ + +*November 6, 2013* + +Django 1.4.10 fixes a Python-compatibility bug in the 1.4 series. + +Python compatibility +-------------------- + +Django 1.4.9 inadvertently introduced issues with Python 2.5 compatibility. Django 1.4.10 restores Python 2.5 compatibility. This was issue #21362 in Django's Trac. \ No newline at end of file diff --git a/setup.py b/setup.py index d0403af5b8cb..da5f86a8208a 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.9.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.10.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From c5d071f85a2a0642110803c945ad628d070088a0 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 7 Nov 2013 09:38:53 -0500 Subject: [PATCH 265/367] [1.4.x] Added 1.4.10 release notes to index. --- docs/releases/1.4.10.txt | 4 +++- docs/releases/index.txt | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/releases/1.4.10.txt b/docs/releases/1.4.10.txt index 59d1646d0b07..97ac1398295a 100644 --- a/docs/releases/1.4.10.txt +++ b/docs/releases/1.4.10.txt @@ -9,4 +9,6 @@ Django 1.4.10 fixes a Python-compatibility bug in the 1.4 series. Python compatibility -------------------- -Django 1.4.9 inadvertently introduced issues with Python 2.5 compatibility. Django 1.4.10 restores Python 2.5 compatibility. This was issue #21362 in Django's Trac. \ No newline at end of file +Django 1.4.9 inadvertently introduced issues with Python 2.5 compatibility. +Django 1.4.10 restores Python 2.5 compatibility. This was issue #21362 in +Django's Trac. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 4673b5a30253..bcc8c7859c58 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.10 1.4.9 1.4.8 1.4.7 From 46755c50ee770719c69a0a105183deb1046cb6af Mon Sep 17 00:00:00 2001 From: Baptiste Mispelon Date: Fri, 15 Mar 2013 19:14:01 +0100 Subject: [PATCH 266/367] [1.4.x] Fix #20054: Removed links to modwsgi.org. Backport of 957fcd0c9fc605bbb69e03296aede3b0bac5a8d2 from master. --- docs/howto/deployment/wsgi/modwsgi.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/howto/deployment/wsgi/modwsgi.txt b/docs/howto/deployment/wsgi/modwsgi.txt index 2562556ca24d..911a2d2cde93 100644 --- a/docs/howto/deployment/wsgi/modwsgi.txt +++ b/docs/howto/deployment/wsgi/modwsgi.txt @@ -18,8 +18,8 @@ The `official mod_wsgi documentation`_ is fantastic; it's your source for all the details about how to use mod_wsgi. You'll probably want to start with the `installation and configuration documentation`_. -.. _official mod_wsgi documentation: http://www.modwsgi.org/ -.. _installation and configuration documentation: http://www.modwsgi.org/wiki/InstallationInstructions +.. _official mod_wsgi documentation: http://code.google.com/p/modwsgi/ +.. _installation and configuration documentation: http://code.google.com/p/modwsgi/wiki/InstallationInstructions Basic configuration =================== From 8e8584f959dce2ad5519421dbb134f5a3014d567 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Sat, 23 Nov 2013 14:47:09 +0100 Subject: [PATCH 267/367] [1.4.x] Removed obsolete deprecation notes. --- docs/internals/deprecation.txt | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 81ca7afa27ce..c45d3f1f21be 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -7,20 +7,6 @@ in a backward incompatible way, following their deprecation, as per the :ref:`deprecation policy `. More details about each item can often be found in the release notes of two versions prior. -1.3 ---- - -See the :doc:`Django 1.1 release notes` for more details on -these changes. - -* ``AdminSite.root()``. This method of hooking up the admin URLs will be - removed in favor of including ``admin.site.urls``. - -* Authentication backends need to define the boolean attributes - ``supports_object_permissions`` and ``supports_anonymous_user`` until - version 1.4, at which point it will be assumed that all backends will - support these options. - 1.4 --- From 23126866ecf3b6375a7c59d5623801fc9b4aee2b Mon Sep 17 00:00:00 2001 From: Alasdair Nicol Date: Mon, 2 Dec 2013 17:21:48 +0000 Subject: [PATCH 268/367] [1.4.x] Fixed #21538 -- Added numpy to test/requirements/base.txt Thanks Tim Graham for the report Backport of c75dd664c from master --- docs/internals/contributing/writing-code/unit-tests.txt | 2 ++ tests/requirements/base.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index 06310962dd1e..70d321ad1ce4 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -141,6 +141,7 @@ Running all the tests If you want to run the full suite of tests, you'll need to install a number of dependencies: +* numpy_ * PIL_ * py-bcrypt_ * PyYAML_ @@ -171,6 +172,7 @@ and install the Geospatial libraries`. Each of these dependencies is optional. If you're missing any of them, the associated tests will be skipped. +.. _numpy: https://pypi.python.org/pypi/numpy .. _PIL: https://pypi.python.org/pypi/PIL .. _py-bcrypt: https://pypi.python.org/pypi/py-bcrypt/ .. _PyYAML: http://pyyaml.org/wiki/PyYAML diff --git a/tests/requirements/base.txt b/tests/requirements/base.txt index 606a8c7e2b2d..c89533b0dfdb 100644 --- a/tests/requirements/base.txt +++ b/tests/requirements/base.txt @@ -1,5 +1,6 @@ docutils Markdown +numpy PIL py-bcrypt python-memcached From 2d4f399ad40c63bbfd7917a9f2006f4ebb495099 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Wed, 4 Dec 2013 16:46:56 +0100 Subject: [PATCH 269/367] [1.4.x] Fixed #21558 -- Support building CHM files. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks Michał Pasternak. Backport of cd9e85ec from master. --- docs/_theme/djangodocs/layout.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/_theme/djangodocs/layout.html b/docs/_theme/djangodocs/layout.html index ef91dd77a92d..caf990c0b810 100644 --- a/docs/_theme/djangodocs/layout.html +++ b/docs/_theme/djangodocs/layout.html @@ -17,6 +17,9 @@ {%- endmacro %} {% block extrahead %} +{# When building htmlhelp (CHM format) disable JQuery inclusion, #} +{# as it causes problems in compiled CHM files. #} +{% if builder != "htmlhelp" %} {{ super() }} +{% endif %} {% endblock %} {% block document %} From 474e7dd6d08105fce66d7f62670a6c120c0b1198 Mon Sep 17 00:00:00 2001 From: Ben Spaulding Date: Wed, 11 Dec 2013 10:28:05 -0700 Subject: [PATCH 270/367] [1.4.x] Fixed #21594 -- Added note about model formsets deleting objects. This behavior has been fixed in 65e03a424e. refs #10284. Backport of de1d5d5df5 from stable/1.6.x. --- docs/topics/forms/modelforms.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index 1aa7d6baaa65..987d0875d7ba 100644 --- a/docs/topics/forms/modelforms.txt +++ b/docs/topics/forms/modelforms.txt @@ -668,6 +668,12 @@ to the database. If your formset contains a ``ManyToManyField``, you'll also need to call ``formset.save_m2m()`` to ensure the many-to-many relationships are saved properly. +.. note:: + + While calling ``formset.save(commit=False)`` does not save new or changed + objects to the database, it *does* delete objects that have been marked for + deletion. This behavior will be corrected in Django 1.7. + .. _model-formsets-max-num: Limiting the number of editable objects From 2c1d92bc64eb5d768d7ac1cc91a4f9d1c2d76f43 Mon Sep 17 00:00:00 2001 From: Luke Plant Date: Thu, 2 Jan 2014 12:39:00 +0000 Subject: [PATCH 271/367] Updated six to version 1.4.1 This is not a bugfix. But six only exists on Django 1.4.x branch to help with future compatibility, so it is helpful if it keeps up with latest Django. --- django/utils/six.py | 287 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 253 insertions(+), 34 deletions(-) diff --git a/django/utils/six.py b/django/utils/six.py index e4ce9398442e..b5f0162393aa 100644 --- a/django/utils/six.py +++ b/django/utils/six.py @@ -1,14 +1,35 @@ """Utilities for writing code that runs on Python 2 and 3""" +# Copyright (c) 2010-2013 Benjamin Peterson +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + import operator import sys import types __author__ = "Benjamin Peterson " -__version__ = "1.2.0" +__version__ = "1.4.1" -# True if we are running on Python 3. +# Useful for very coarse version differentiation. +PY2 = sys.version_info[0] == 2 PY3 = sys.version_info[0] == 3 if PY3: @@ -26,7 +47,7 @@ text_type = unicode binary_type = str - if sys.platform == "java": + if sys.platform.startswith("java"): # Jython always uses 32 bits. MAXSIZE = int((1 << 31) - 1) else: @@ -42,7 +63,7 @@ def __len__(self): else: # 64-bit MAXSIZE = int((1 << 63) - 1) - del X + del X def _add_doc(func, doc): @@ -109,7 +130,6 @@ def _resolve(self): return getattr(module, self.attr) - class _MovedItems(types.ModuleType): """Lazy loading of moved objects""" @@ -117,13 +137,17 @@ class _MovedItems(types.ModuleType): _moved_attributes = [ MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), + MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"), MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), MovedAttribute("map", "itertools", "builtins", "imap", "map"), + MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"), MovedAttribute("reload_module", "__builtin__", "imp", "reload"), MovedAttribute("reduce", "__builtin__", "functools"), MovedAttribute("StringIO", "StringIO", "io"), + MovedAttribute("UserString", "UserString", "collections"), MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), + MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"), MovedModule("builtins", "__builtin__"), MovedModule("configparser", "ConfigParser"), @@ -133,6 +157,9 @@ class _MovedItems(types.ModuleType): MovedModule("html_entities", "htmlentitydefs", "html.entities"), MovedModule("html_parser", "HTMLParser", "html.parser"), MovedModule("http_client", "httplib", "http.client"), + MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"), + MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"), + MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"), MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), @@ -157,6 +184,9 @@ class _MovedItems(types.ModuleType): MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", "tkinter.simpledialog"), + MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"), + MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"), + MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), MovedModule("winreg", "_winreg"), ] @@ -164,7 +194,143 @@ class _MovedItems(types.ModuleType): setattr(_MovedItems, attr.name, attr) del attr -moves = sys.modules["django.utils.six.moves"] = _MovedItems("moves") +moves = sys.modules[__name__ + ".moves"] = _MovedItems(__name__ + ".moves") + + +class Module_six_moves_urllib_parse(types.ModuleType): + """Lazy loading of moved objects in six.moves.urllib_parse""" + + +_urllib_parse_moved_attributes = [ + MovedAttribute("ParseResult", "urlparse", "urllib.parse"), + MovedAttribute("parse_qs", "urlparse", "urllib.parse"), + MovedAttribute("parse_qsl", "urlparse", "urllib.parse"), + MovedAttribute("urldefrag", "urlparse", "urllib.parse"), + MovedAttribute("urljoin", "urlparse", "urllib.parse"), + MovedAttribute("urlparse", "urlparse", "urllib.parse"), + MovedAttribute("urlsplit", "urlparse", "urllib.parse"), + MovedAttribute("urlunparse", "urlparse", "urllib.parse"), + MovedAttribute("urlunsplit", "urlparse", "urllib.parse"), + MovedAttribute("quote", "urllib", "urllib.parse"), + MovedAttribute("quote_plus", "urllib", "urllib.parse"), + MovedAttribute("unquote", "urllib", "urllib.parse"), + MovedAttribute("unquote_plus", "urllib", "urllib.parse"), + MovedAttribute("urlencode", "urllib", "urllib.parse"), +] +for attr in _urllib_parse_moved_attributes: + setattr(Module_six_moves_urllib_parse, attr.name, attr) +del attr + +sys.modules[__name__ + ".moves.urllib_parse"] = Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse") +sys.modules[__name__ + ".moves.urllib.parse"] = Module_six_moves_urllib_parse(__name__ + ".moves.urllib.parse") + + +class Module_six_moves_urllib_error(types.ModuleType): + """Lazy loading of moved objects in six.moves.urllib_error""" + + +_urllib_error_moved_attributes = [ + MovedAttribute("URLError", "urllib2", "urllib.error"), + MovedAttribute("HTTPError", "urllib2", "urllib.error"), + MovedAttribute("ContentTooShortError", "urllib", "urllib.error"), +] +for attr in _urllib_error_moved_attributes: + setattr(Module_six_moves_urllib_error, attr.name, attr) +del attr + +sys.modules[__name__ + ".moves.urllib_error"] = Module_six_moves_urllib_error(__name__ + ".moves.urllib_error") +sys.modules[__name__ + ".moves.urllib.error"] = Module_six_moves_urllib_error(__name__ + ".moves.urllib.error") + + +class Module_six_moves_urllib_request(types.ModuleType): + """Lazy loading of moved objects in six.moves.urllib_request""" + + +_urllib_request_moved_attributes = [ + MovedAttribute("urlopen", "urllib2", "urllib.request"), + MovedAttribute("install_opener", "urllib2", "urllib.request"), + MovedAttribute("build_opener", "urllib2", "urllib.request"), + MovedAttribute("pathname2url", "urllib", "urllib.request"), + MovedAttribute("url2pathname", "urllib", "urllib.request"), + MovedAttribute("getproxies", "urllib", "urllib.request"), + MovedAttribute("Request", "urllib2", "urllib.request"), + MovedAttribute("OpenerDirector", "urllib2", "urllib.request"), + MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"), + MovedAttribute("ProxyHandler", "urllib2", "urllib.request"), + MovedAttribute("BaseHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"), + MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"), + MovedAttribute("FileHandler", "urllib2", "urllib.request"), + MovedAttribute("FTPHandler", "urllib2", "urllib.request"), + MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"), + MovedAttribute("UnknownHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"), + MovedAttribute("urlretrieve", "urllib", "urllib.request"), + MovedAttribute("urlcleanup", "urllib", "urllib.request"), + MovedAttribute("URLopener", "urllib", "urllib.request"), + MovedAttribute("FancyURLopener", "urllib", "urllib.request"), +] +for attr in _urllib_request_moved_attributes: + setattr(Module_six_moves_urllib_request, attr.name, attr) +del attr + +sys.modules[__name__ + ".moves.urllib_request"] = Module_six_moves_urllib_request(__name__ + ".moves.urllib_request") +sys.modules[__name__ + ".moves.urllib.request"] = Module_six_moves_urllib_request(__name__ + ".moves.urllib.request") + + +class Module_six_moves_urllib_response(types.ModuleType): + """Lazy loading of moved objects in six.moves.urllib_response""" + + +_urllib_response_moved_attributes = [ + MovedAttribute("addbase", "urllib", "urllib.response"), + MovedAttribute("addclosehook", "urllib", "urllib.response"), + MovedAttribute("addinfo", "urllib", "urllib.response"), + MovedAttribute("addinfourl", "urllib", "urllib.response"), +] +for attr in _urllib_response_moved_attributes: + setattr(Module_six_moves_urllib_response, attr.name, attr) +del attr + +sys.modules[__name__ + ".moves.urllib_response"] = Module_six_moves_urllib_response(__name__ + ".moves.urllib_response") +sys.modules[__name__ + ".moves.urllib.response"] = Module_six_moves_urllib_response(__name__ + ".moves.urllib.response") + + +class Module_six_moves_urllib_robotparser(types.ModuleType): + """Lazy loading of moved objects in six.moves.urllib_robotparser""" + + +_urllib_robotparser_moved_attributes = [ + MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"), +] +for attr in _urllib_robotparser_moved_attributes: + setattr(Module_six_moves_urllib_robotparser, attr.name, attr) +del attr + +sys.modules[__name__ + ".moves.urllib_robotparser"] = Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib_robotparser") +sys.modules[__name__ + ".moves.urllib.robotparser"] = Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser") + + +class Module_six_moves_urllib(types.ModuleType): + """Create a six.moves.urllib namespace that resembles the Python 3 namespace""" + parse = sys.modules[__name__ + ".moves.urllib_parse"] + error = sys.modules[__name__ + ".moves.urllib_error"] + request = sys.modules[__name__ + ".moves.urllib_request"] + response = sys.modules[__name__ + ".moves.urllib_response"] + robotparser = sys.modules[__name__ + ".moves.urllib_robotparser"] + + +sys.modules[__name__ + ".moves.urllib"] = Module_six_moves_urllib(__name__ + ".moves.urllib") def add_move(move): @@ -187,22 +353,28 @@ def remove_move(name): _meth_func = "__func__" _meth_self = "__self__" + _func_closure = "__closure__" _func_code = "__code__" _func_defaults = "__defaults__" + _func_globals = "__globals__" _iterkeys = "keys" _itervalues = "values" _iteritems = "items" + _iterlists = "lists" else: _meth_func = "im_func" _meth_self = "im_self" + _func_closure = "func_closure" _func_code = "func_code" _func_defaults = "func_defaults" + _func_globals = "func_globals" _iterkeys = "iterkeys" _itervalues = "itervalues" _iteritems = "iteritems" + _iterlists = "iterlists" try: @@ -213,18 +385,27 @@ def advance_iterator(it): next = advance_iterator +try: + callable = callable +except NameError: + def callable(obj): + return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) + + if PY3: def get_unbound_function(unbound): return unbound - Iterator = object + create_bound_method = types.MethodType - def callable(obj): - return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) + Iterator = object else: def get_unbound_function(unbound): return unbound.im_func + def create_bound_method(func, obj): + return types.MethodType(func, obj, obj.__class__) + class Iterator(object): def next(self): @@ -237,21 +418,27 @@ def next(self): get_method_function = operator.attrgetter(_meth_func) get_method_self = operator.attrgetter(_meth_self) +get_function_closure = operator.attrgetter(_func_closure) get_function_code = operator.attrgetter(_func_code) get_function_defaults = operator.attrgetter(_func_defaults) +get_function_globals = operator.attrgetter(_func_globals) -def iterkeys(d): +def iterkeys(d, **kw): """Return an iterator over the keys of a dictionary.""" - return iter(getattr(d, _iterkeys)()) + return iter(getattr(d, _iterkeys)(**kw)) -def itervalues(d): +def itervalues(d, **kw): """Return an iterator over the values of a dictionary.""" - return iter(getattr(d, _itervalues)()) + return iter(getattr(d, _itervalues)(**kw)) -def iteritems(d): +def iteritems(d, **kw): """Return an iterator over the (key, value) pairs of a dictionary.""" - return iter(getattr(d, _iteritems)()) + return iter(getattr(d, _iteritems)(**kw)) + +def iterlists(d, **kw): + """Return an iterator over the (key, [values]) pairs of a dictionary.""" + return iter(getattr(d, _iterlists)(**kw)) if PY3: @@ -259,12 +446,16 @@ def b(s): return s.encode("latin-1") def u(s): return s + unichr = chr if sys.version_info[1] <= 1: def int2byte(i): return bytes((i,)) else: # This is about 2x faster than the implementation above on 3.2+ int2byte = operator.methodcaller("to_bytes", 1, "big") + byte2int = operator.itemgetter(0) + indexbytes = operator.getitem + iterbytes = iter import io StringIO = io.StringIO BytesIO = io.BytesIO @@ -273,7 +464,14 @@ def b(s): return s def u(s): return unicode(s, "unicode_escape") + unichr = unichr int2byte = chr + def byte2int(bs): + return ord(bs[0]) + def indexbytes(buf, i): + return ord(buf[i]) + def iterbytes(buf): + return (ord(byte) for byte in buf) import StringIO StringIO = BytesIO = StringIO.StringIO _add_doc(b, """Byte literal""") @@ -284,35 +482,31 @@ def u(s): import builtins exec_ = getattr(builtins, "exec") - def reraise(tp, value, tb=None): if value.__traceback__ is not tb: raise value.with_traceback(tb) raise value - print_ = getattr(builtins, "print") del builtins else: - def exec_(code, globs=None, locs=None): + def exec_(_code_, _globs_=None, _locs_=None): """Execute code in a namespace.""" - if globs is None: + if _globs_ is None: frame = sys._getframe(1) - globs = frame.f_globals - if locs is None: - locs = frame.f_locals + _globs_ = frame.f_globals + if _locs_ is None: + _locs_ = frame.f_locals del frame - elif locs is None: - locs = globs - exec("""exec code in globs, locs""") - + elif _locs_ is None: + _locs_ = _globs_ + exec("""exec _code_ in _globs_, _locs_""") exec_("""def reraise(tp, value, tb=None): raise tp, value, tb """) - def print_(*args, **kwargs): """The new-style print function.""" fp = kwargs.pop("file", sys.stdout) @@ -361,21 +555,46 @@ def write(data): _add_doc(reraise, """Reraise an exception.""") -def with_metaclass(meta, base=object): +def with_metaclass(meta, *bases): """Create a base class with a metaclass.""" - return meta("NewBase", (base,), {}) + return meta("NewBase", bases, {}) + +def add_metaclass(metaclass): + """Class decorator for creating a class with a metaclass.""" + def wrapper(cls): + orig_vars = cls.__dict__.copy() + orig_vars.pop('__dict__', None) + orig_vars.pop('__weakref__', None) + for slots_var in orig_vars.get('__slots__', ()): + orig_vars.pop(slots_var) + return metaclass(cls.__name__, cls.__bases__, orig_vars) + return wrapper ### Additional customizations for Django ### if PY3: - _iterlists = "lists" + _assertRaisesRegex = "assertRaisesRegex" + _assertRegex = "assertRegex" + memoryview = memoryview else: - _iterlists = "iterlists" + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + # memoryview and buffer are not stricly equivalent, but should be fine for + # django core usage (mainly BinaryField). However, Jython doesn't support + # buffer (see http://bugs.jython.org/issue1521), so we have to be careful. + if sys.platform.startswith('java'): + memoryview = memoryview + else: + memoryview = buffer + + +def assertRaisesRegex(self, *args, **kwargs): + return getattr(self, _assertRaisesRegex)(*args, **kwargs) + -def iterlists(d): - """Return an iterator over the values of a MultiValueDict.""" - return getattr(d, _iterlists)() +def assertRegex(self, *args, **kwargs): + return getattr(self, _assertRegex)(*args, **kwargs) add_move(MovedModule("_dummy_thread", "dummy_thread")) From 1036e3ec7c7d7f99e3d9138e8f461c2cc690e03c Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 17 Jan 2014 09:21:34 -0500 Subject: [PATCH 272/367] [1.4.x] Fixed #20052 -- Discouraged use of Jython given the current state of django-jython. Thanks Josh Juneau (maintainer of django-jython) for the review. Backport of a67e327db5 from master --- docs/howto/jython.txt | 75 +++++++------------------------------------ 1 file changed, 11 insertions(+), 64 deletions(-) diff --git a/docs/howto/jython.txt b/docs/howto/jython.txt index f485d0be9291..28cd2c9f4a83 100644 --- a/docs/howto/jython.txt +++ b/docs/howto/jython.txt @@ -4,70 +4,17 @@ Running Django on Jython .. index:: Jython, Java, JVM -Jython_ is an implementation of Python that runs on the Java platform (JVM). -Django runs cleanly on Jython version 2.5 or later, which means you can deploy -Django on any Java platform. +As of January 2014, the latest release of `django-jython`_ supports Django 1.3 +which is no longer supported (receiving fixes or security updates) by the +Django Project. We therefore recommend that you do not try to run Django on +Jython at this time. -This document will get you up and running with Django on top of Jython. - -.. _jython: http://www.jython.org/ - -Installing Jython -================= - -Django works with Jython versions 2.5b3 and higher. Download Jython at -http://www.jython.org/. - -Creating a servlet container -============================ - -If you just want to experiment with Django, skip ahead to the next section; -Django includes a lightweight Web server you can use for testing, so you won't -need to set up anything else until you're ready to deploy Django in production. - -If you want to use Django on a production site, use a Java servlet container, -such as `Apache Tomcat`_. Full JavaEE applications servers such as `GlassFish`_ -or `JBoss`_ are also OK, if you need the extra features they include. - -.. _`Apache Tomcat`: http://tomcat.apache.org/ -.. _GlassFish: http://glassfish.java.net/ -.. _JBoss: http://www.jboss.org/ - -Installing Django -================= - -The next step is to install Django itself. This is exactly the same as -installing Django on standard Python, so see -:ref:`removing-old-versions-of-django` and :ref:`install-django-code` for -instructions. - -Installing Jython platform support libraries -============================================ - -The `django-jython`_ project contains database backends and management commands -for Django/Jython development. Note that the builtin Django backends won't work -on top of Jython. +The django-jython project is `seeking contributors`_ to help update its code for +newer versions of Django. You can select an older version of this documentation +to see the instructions we had for using Django with Jython. If django-jython +is updated and please `file a ticket`_ and we'll be happy to update our +documentation accordingly. .. _`django-jython`: http://code.google.com/p/django-jython/ - -To install it, follow the `installation instructions`_ detailed on the project -Web site. Also, read the `database backends`_ documentation there. - -.. _`installation instructions`: http://code.google.com/p/django-jython/wiki/Install -.. _`database backends`: http://code.google.com/p/django-jython/wiki/DatabaseBackends - -Differences with Django on Jython -================================= - -.. index:: JYTHONPATH - -At this point, Django on Jython should behave nearly identically to Django -running on standard Python. However, are a few differences to keep in mind: - -* Remember to use the ``jython`` command instead of ``python``. The - documentation uses ``python`` for consistency, but if you're using Jython - you'll want to mentally replace ``python`` with ``jython`` every time it - occurs. - -* Similarly, you'll need to use the ``JYTHONPATH`` environment variable - instead of ``PYTHONPATH``. +.. _`seeking contributors`: https://groups.google.com/d/topic/django-jython-dev/oZpKucQpz7I/discussion +.. _`file a ticket`: https://code.djangoproject.com/newticket From 03d9b9ea0af403bf27ce552b9f8262b47bfdff0e Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Sat, 7 Sep 2013 12:46:42 -0500 Subject: [PATCH 273/367] [1.4.x] Added a note about LTS releases. Backport of a44cbca2a5f1388c6511dad48443877fa660845a from master. --- docs/internals/release-process.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/internals/release-process.txt b/docs/internals/release-process.txt index 8ead4d7ae72c..3b8e3c2a98eb 100644 --- a/docs/internals/release-process.txt +++ b/docs/internals/release-process.txt @@ -133,6 +133,20 @@ Django 1.3 and 1.4. At this point in time: * Documentation fixes will be applied to trunk, and, if easily backported, to the ``1.3.X`` branch. +.. _lts-releases: + +Long-term support (LTS) releases +================================ + +Additionally, the Django team will occasionally designate certain releases +to be "Long-term support" (LTS) releases. LTS releases will get security fixes +applied for a guaranteed period of time, typically 3+ years, regardless of +the pace of releases afterwards. + +The follow releases have been designated for long-term support: + + * Django 1.4, supported until at least March 2015. + .. _release-process: Release process From 85057522bc220585363e637528d970d3802475e8 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 24 Jan 2014 08:52:43 -0500 Subject: [PATCH 274/367] [1.4.x] Fixed #21869 -- Fixed docs building with Sphinx 1.2.1. Thanks tragiclifestories for the report. Backport of e1d18b9d2e from master --- docs/_ext/djangodocs.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/_ext/djangodocs.py b/docs/_ext/djangodocs.py index 37f5e20a640e..339801747128 100644 --- a/docs/_ext/djangodocs.py +++ b/docs/_ext/djangodocs.py @@ -114,11 +114,13 @@ def depart_table(self, node): self.compact_p = self.context.pop() self.body.append('\n') - # ? Really? def visit_desc_parameterlist(self, node): - self.body.append('(') + self.body.append('(') # by default sphinx puts around the "(" self.first_param = 1 + self.optional_param_level = 0 self.param_separator = node.child_text_separator + self.required_params_left = sum([isinstance(c, addnodes.desc_parameter) + for c in node.children]) def depart_desc_parameterlist(self, node): self.body.append(')') From 257f8528b722ea943b2f2113fc2135743e2d80dc Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 26 Jan 2014 15:52:39 -0500 Subject: [PATCH 275/367] [1.4.x] Fixed #21823 -- Upgraded six to 1.5.2 Backport of 780ae7e9f8 from master. --- django/utils/six.py | 119 +++++++++++++++++++++++++++++++++----------- 1 file changed, 90 insertions(+), 29 deletions(-) diff --git a/django/utils/six.py b/django/utils/six.py index b5f0162393aa..0b1b3f93d388 100644 --- a/django/utils/six.py +++ b/django/utils/six.py @@ -1,6 +1,6 @@ """Utilities for writing code that runs on Python 2 and 3""" -# Copyright (c) 2010-2013 Benjamin Peterson +# Copyright (c) 2010-2014 Benjamin Peterson # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -25,7 +25,7 @@ import types __author__ = "Benjamin Peterson " -__version__ = "1.4.1" +__version__ = "1.5.2" # Useful for very coarse version differentiation. @@ -84,9 +84,9 @@ def __init__(self, name): def __get__(self, obj, tp): result = self._resolve() - setattr(obj, self.name, result) + setattr(obj, self.name, result) # Invokes __set__. # This is a bit ugly, but it avoids running this again. - delattr(tp, self.name) + delattr(obj.__class__, self.name) return result @@ -104,6 +104,35 @@ def __init__(self, name, old, new=None): def _resolve(self): return _import_module(self.mod) + def __getattr__(self, attr): + # Hack around the Django autoreloader. The reloader tries to get + # __file__ or __name__ of every module in sys.modules. This doesn't work + # well if this MovedModule is for an module that is unavailable on this + # machine (like winreg on Unix systems). Thus, we pretend __file__ and + # __name__ don't exist if the module hasn't been loaded yet. See issues + # #51 and #53. + if attr in ("__file__", "__name__") and self.mod not in sys.modules: + raise AttributeError + _module = self._resolve() + value = getattr(_module, attr) + setattr(self, attr, value) + return value + + +class _LazyModule(types.ModuleType): + + def __init__(self, name): + super(_LazyModule, self).__init__(name) + self.__doc__ = self.__class__.__doc__ + + def __dir__(self): + attrs = ["__doc__", "__name__"] + attrs += [attr.name for attr in self._moved_attributes] + return attrs + + # Subclasses should override this + _moved_attributes = [] + class MovedAttribute(_LazyDescr): @@ -130,7 +159,8 @@ def _resolve(self): return getattr(module, self.attr) -class _MovedItems(types.ModuleType): + +class _MovedItems(_LazyModule): """Lazy loading of moved objects""" @@ -152,6 +182,7 @@ class _MovedItems(types.ModuleType): MovedModule("builtins", "__builtin__"), MovedModule("configparser", "ConfigParser"), MovedModule("copyreg", "copy_reg"), + MovedModule("dbm_gnu", "gdbm", "dbm.gnu"), MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), MovedModule("http_cookies", "Cookie", "http.cookies"), MovedModule("html_entities", "htmlentitydefs", "html.entities"), @@ -167,12 +198,14 @@ class _MovedItems(types.ModuleType): MovedModule("queue", "Queue"), MovedModule("reprlib", "repr"), MovedModule("socketserver", "SocketServer"), + MovedModule("_thread", "thread", "_thread"), MovedModule("tkinter", "Tkinter"), MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), MovedModule("tkinter_tix", "Tix", "tkinter.tix"), + MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"), MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), MovedModule("tkinter_colorchooser", "tkColorChooser", @@ -188,16 +221,21 @@ class _MovedItems(types.ModuleType): MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"), MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), + MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), MovedModule("winreg", "_winreg"), ] for attr in _moved_attributes: setattr(_MovedItems, attr.name, attr) + if isinstance(attr, MovedModule): + sys.modules[__name__ + ".moves." + attr.name] = attr del attr +_MovedItems._moved_attributes = _moved_attributes + moves = sys.modules[__name__ + ".moves"] = _MovedItems(__name__ + ".moves") -class Module_six_moves_urllib_parse(types.ModuleType): +class Module_six_moves_urllib_parse(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_parse""" @@ -221,11 +259,12 @@ class Module_six_moves_urllib_parse(types.ModuleType): setattr(Module_six_moves_urllib_parse, attr.name, attr) del attr -sys.modules[__name__ + ".moves.urllib_parse"] = Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse") -sys.modules[__name__ + ".moves.urllib.parse"] = Module_six_moves_urllib_parse(__name__ + ".moves.urllib.parse") +Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes + +sys.modules[__name__ + ".moves.urllib_parse"] = sys.modules[__name__ + ".moves.urllib.parse"] = Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse") -class Module_six_moves_urllib_error(types.ModuleType): +class Module_six_moves_urllib_error(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_error""" @@ -238,11 +277,12 @@ class Module_six_moves_urllib_error(types.ModuleType): setattr(Module_six_moves_urllib_error, attr.name, attr) del attr -sys.modules[__name__ + ".moves.urllib_error"] = Module_six_moves_urllib_error(__name__ + ".moves.urllib_error") -sys.modules[__name__ + ".moves.urllib.error"] = Module_six_moves_urllib_error(__name__ + ".moves.urllib.error") +Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes +sys.modules[__name__ + ".moves.urllib_error"] = sys.modules[__name__ + ".moves.urllib.error"] = Module_six_moves_urllib_error(__name__ + ".moves.urllib.error") -class Module_six_moves_urllib_request(types.ModuleType): + +class Module_six_moves_urllib_request(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_request""" @@ -279,16 +319,18 @@ class Module_six_moves_urllib_request(types.ModuleType): MovedAttribute("urlcleanup", "urllib", "urllib.request"), MovedAttribute("URLopener", "urllib", "urllib.request"), MovedAttribute("FancyURLopener", "urllib", "urllib.request"), + MovedAttribute("proxy_bypass", "urllib", "urllib.request"), ] for attr in _urllib_request_moved_attributes: setattr(Module_six_moves_urllib_request, attr.name, attr) del attr -sys.modules[__name__ + ".moves.urllib_request"] = Module_six_moves_urllib_request(__name__ + ".moves.urllib_request") -sys.modules[__name__ + ".moves.urllib.request"] = Module_six_moves_urllib_request(__name__ + ".moves.urllib.request") +Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes + +sys.modules[__name__ + ".moves.urllib_request"] = sys.modules[__name__ + ".moves.urllib.request"] = Module_six_moves_urllib_request(__name__ + ".moves.urllib.request") -class Module_six_moves_urllib_response(types.ModuleType): +class Module_six_moves_urllib_response(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_response""" @@ -302,11 +344,12 @@ class Module_six_moves_urllib_response(types.ModuleType): setattr(Module_six_moves_urllib_response, attr.name, attr) del attr -sys.modules[__name__ + ".moves.urllib_response"] = Module_six_moves_urllib_response(__name__ + ".moves.urllib_response") -sys.modules[__name__ + ".moves.urllib.response"] = Module_six_moves_urllib_response(__name__ + ".moves.urllib.response") +Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes + +sys.modules[__name__ + ".moves.urllib_response"] = sys.modules[__name__ + ".moves.urllib.response"] = Module_six_moves_urllib_response(__name__ + ".moves.urllib.response") -class Module_six_moves_urllib_robotparser(types.ModuleType): +class Module_six_moves_urllib_robotparser(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_robotparser""" @@ -317,8 +360,9 @@ class Module_six_moves_urllib_robotparser(types.ModuleType): setattr(Module_six_moves_urllib_robotparser, attr.name, attr) del attr -sys.modules[__name__ + ".moves.urllib_robotparser"] = Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib_robotparser") -sys.modules[__name__ + ".moves.urllib.robotparser"] = Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser") +Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes + +sys.modules[__name__ + ".moves.urllib_robotparser"] = sys.modules[__name__ + ".moves.urllib.robotparser"] = Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser") class Module_six_moves_urllib(types.ModuleType): @@ -329,6 +373,9 @@ class Module_six_moves_urllib(types.ModuleType): response = sys.modules[__name__ + ".moves.urllib_response"] robotparser = sys.modules[__name__ + ".moves.urllib_robotparser"] + def __dir__(self): + return ['parse', 'error', 'request', 'response', 'robotparser'] + sys.modules[__name__ + ".moves.urllib"] = Module_six_moves_urllib(__name__ + ".moves.urllib") @@ -462,8 +509,9 @@ def int2byte(i): else: def b(s): return s + # Workaround for standalone backslash def u(s): - return unicode(s, "unicode_escape") + return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape") unichr = unichr int2byte = chr def byte2int(bs): @@ -479,17 +527,14 @@ def iterbytes(buf): if PY3: - import builtins - exec_ = getattr(builtins, "exec") + exec_ = getattr(moves.builtins, "exec") + def reraise(tp, value, tb=None): if value.__traceback__ is not tb: raise value.with_traceback(tb) raise value - print_ = getattr(builtins, "print") - del builtins - else: def exec_(_code_, _globs_=None, _locs_=None): """Execute code in a namespace.""" @@ -503,18 +548,30 @@ def exec_(_code_, _globs_=None, _locs_=None): _locs_ = _globs_ exec("""exec _code_ in _globs_, _locs_""") + exec_("""def reraise(tp, value, tb=None): raise tp, value, tb """) + +print_ = getattr(moves.builtins, "print", None) +if print_ is None: def print_(*args, **kwargs): - """The new-style print function.""" + """The new-style print function for Python 2.4 and 2.5.""" fp = kwargs.pop("file", sys.stdout) if fp is None: return def write(data): if not isinstance(data, basestring): data = str(data) + # If the file has an encoding, encode unicode with it. + if (isinstance(fp, file) and + isinstance(data, unicode) and + fp.encoding is not None): + errors = getattr(fp, "errors", None) + if errors is None: + errors = "strict" + data = data.encode(fp.encoding, errors) fp.write(data) want_unicode = False sep = kwargs.pop("sep", None) @@ -565,8 +622,12 @@ def wrapper(cls): orig_vars = cls.__dict__.copy() orig_vars.pop('__dict__', None) orig_vars.pop('__weakref__', None) - for slots_var in orig_vars.get('__slots__', ()): - orig_vars.pop(slots_var) + slots = orig_vars.get('__slots__') + if slots is not None: + if isinstance(slots, str): + slots = [slots] + for slots_var in slots: + orig_vars.pop(slots_var) return metaclass(cls.__name__, cls.__bases__, orig_vars) return wrapper From 74181c0a2c5eb52c63666f830403c9331fc1ad69 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 26 Jan 2014 17:48:28 -0500 Subject: [PATCH 276/367] [1.4.x] Added release note stub for 1.4.11. Backport of dfa28981ce from master. --- docs/releases/1.4.11.txt | 8 ++++++++ docs/releases/index.txt | 1 + 2 files changed, 9 insertions(+) create mode 100644 docs/releases/1.4.11.txt diff --git a/docs/releases/1.4.11.txt b/docs/releases/1.4.11.txt new file mode 100644 index 000000000000..b0879efaf37b --- /dev/null +++ b/docs/releases/1.4.11.txt @@ -0,0 +1,8 @@ +=========================== +Django 1.4.11 release notes +=========================== + +*Under development* + +Django's vendored version of six, :mod:`django.utils.six`, has been upgraded to +the latest release (1.5.2). diff --git a/docs/releases/index.txt b/docs/releases/index.txt index bcc8c7859c58..81ac94f26849 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.11 1.4.10 1.4.9 1.4.8 From b8713ee69a7a171a01cf94c56d3b83bc09e41506 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 1 Oct 2013 10:08:36 -0400 Subject: [PATCH 277/367] [1.4.x] Fixed #21195 -- Clarifed usage of template_name in tutorial part 4. Backport of b66a51ad545ac726ef98966cbc35ee7aefdff8cd from master. --- docs/intro/tutorial04.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro/tutorial04.txt b/docs/intro/tutorial04.txt index 6a55917fd591..d3af620b88c2 100644 --- a/docs/intro/tutorial04.txt +++ b/docs/intro/tutorial04.txt @@ -276,7 +276,7 @@ two views abstract the concepts of "display a list of objects" and By default, the :class:`~django.views.generic.list.DetailView` generic view uses a template called ``/_detail.html``. -In our case, it'll use the template ``"polls/poll_detail.html"``. The +In our case, it would use the template ``"polls/poll_detail.html"``. The ``template_name`` argument is used to tell Django to use a specific template name instead of the autogenerated default template name. We also specify the ``template_name`` for the ``results`` list view -- From f108b1f7d79526fb2fc0a6ff212744cffb399d15 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sat, 22 Mar 2014 11:14:15 +0100 Subject: [PATCH 278/367] [1.4.x] Clarified striptags documentation The fact that striptags cannot guarantee to really strip all non-safe HTML content was not clear enough. Also see: https://www.djangoproject.com/weblog/2014/mar/22/strip-tags-advisory/ Partial backport (doc-only) of 6ca6c36f82 from master. --- docs/ref/templates/builtins.txt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index e911bb167bc8..e2734c974791 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -1988,7 +1988,7 @@ If ``value`` is ``"Joel is a slug"``, the output will be ``"Joel is a slug"``. striptags ^^^^^^^^^ -Strips all [X]HTML tags. +Makes all possible efforts to strip all [X]HTML tags. For example:: @@ -1997,6 +1997,16 @@ For example:: If ``value`` is ``"Joel a slug"``, the output will be ``"Joel is a slug"``. +.. admonition:: No safety guarantee + + Note that ``striptags`` doesn't give any guarantee about its output being + entirely HTML safe, particularly with non valid HTML input. So **NEVER** + apply the ``safe`` filter to a ``striptags`` output. + If you are looking for something more robust, you can use the ``bleach`` + Python library, notably its `clean`_ method. + +.. _clean: http://bleach.readthedocs.org/en/latest/clean.html + .. templatefilter:: time time From f2a9f715657105306e6cf0b978be60bb6f745c8a Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 24 Mar 2014 07:33:04 -0400 Subject: [PATCH 279/367] [1.4.x] Updated six to 1.6.1. Backport of 2ec82c7387db071278201796208808de84c90dbf from master --- django/utils/six.py | 36 +++++++++++++++++++++++++----------- docs/releases/1.4.11.txt | 2 +- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/django/utils/six.py b/django/utils/six.py index 0b1b3f93d388..26370d7741ed 100644 --- a/django/utils/six.py +++ b/django/utils/six.py @@ -25,7 +25,7 @@ import types __author__ = "Benjamin Peterson " -__version__ = "1.5.2" +__version__ = "1.6.1" # Useful for very coarse version differentiation. @@ -83,7 +83,11 @@ def __init__(self, name): self.name = name def __get__(self, obj, tp): - result = self._resolve() + try: + result = self._resolve() + except ImportError: + # See the nice big comment in MovedModule.__getattr__. + raise AttributeError("%s could not be imported " % self.name) setattr(obj, self.name, result) # Invokes __set__. # This is a bit ugly, but it avoids running this again. delattr(obj.__class__, self.name) @@ -105,15 +109,22 @@ def _resolve(self): return _import_module(self.mod) def __getattr__(self, attr): - # Hack around the Django autoreloader. The reloader tries to get - # __file__ or __name__ of every module in sys.modules. This doesn't work - # well if this MovedModule is for an module that is unavailable on this - # machine (like winreg on Unix systems). Thus, we pretend __file__ and - # __name__ don't exist if the module hasn't been loaded yet. See issues - # #51 and #53. - if attr in ("__file__", "__name__") and self.mod not in sys.modules: - raise AttributeError - _module = self._resolve() + # It turns out many Python frameworks like to traverse sys.modules and + # try to load various attributes. This causes problems if this is a + # platform-specific module on the wrong platform, like _winreg on + # Unixes. Therefore, we silently pretend unimportable modules do not + # have any attributes. See issues #51, #53, #56, and #63 for the full + # tales of woe. + # + # First, if possible, avoid loading the module just to look at __file__, + # __name__, or __path__. + if (attr in ("__file__", "__name__", "__path__") and + self.mod not in sys.modules): + raise AttributeError(attr) + try: + _module = self._resolve() + except ImportError: + raise AttributeError(attr) value = getattr(_module, attr) setattr(self, attr, value) return value @@ -222,6 +233,7 @@ class _MovedItems(_LazyModule): MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), + MovedModule("xmlrpc_server", "xmlrpclib", "xmlrpc.server"), MovedModule("winreg", "_winreg"), ] for attr in _moved_attributes: @@ -241,6 +253,7 @@ class Module_six_moves_urllib_parse(_LazyModule): _urllib_parse_moved_attributes = [ MovedAttribute("ParseResult", "urlparse", "urllib.parse"), + MovedAttribute("SplitResult", "urlparse", "urllib.parse"), MovedAttribute("parse_qs", "urlparse", "urllib.parse"), MovedAttribute("parse_qsl", "urlparse", "urllib.parse"), MovedAttribute("urldefrag", "urlparse", "urllib.parse"), @@ -254,6 +267,7 @@ class Module_six_moves_urllib_parse(_LazyModule): MovedAttribute("unquote", "urllib", "urllib.parse"), MovedAttribute("unquote_plus", "urllib", "urllib.parse"), MovedAttribute("urlencode", "urllib", "urllib.parse"), + MovedAttribute("splitquery", "urllib", "urllib.parse"), ] for attr in _urllib_parse_moved_attributes: setattr(Module_six_moves_urllib_parse, attr.name, attr) diff --git a/docs/releases/1.4.11.txt b/docs/releases/1.4.11.txt index b0879efaf37b..b42b20aa7b02 100644 --- a/docs/releases/1.4.11.txt +++ b/docs/releases/1.4.11.txt @@ -5,4 +5,4 @@ Django 1.4.11 release notes *Under development* Django's vendored version of six, :mod:`django.utils.six`, has been upgraded to -the latest release (1.5.2). +the latest release (1.6.1). From 83420e70ef98c015207f8a49bd35ebb098ac6df5 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 19 Apr 2014 13:01:52 -0400 Subject: [PATCH 280/367] [1.4.x] Fixed random aggregation_regress test_more_more_more() failure The cause was assuming that an unordered queryset returns the values always in the same order. Backport of 33dd8f544205be923e2a06106909ebcd3583526b --- tests/regressiontests/aggregation_regress/tests.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/regressiontests/aggregation_regress/tests.py b/tests/regressiontests/aggregation_regress/tests.py index 36a54c0b1742..11ad6acdc109 100644 --- a/tests/regressiontests/aggregation_regress/tests.py +++ b/tests/regressiontests/aggregation_regress/tests.py @@ -587,10 +587,9 @@ def test_more_more_more(self): ) publishers = publishers.annotate(n_books=Count("book")) - self.assertEqual( - publishers[0].n_books, - 2 - ) + sorted_publishers = sorted(publishers, key=lambda x: x.name) + self.assertEqual(sorted_publishers[0].n_books, 2) + self.assertEqual(sorted_publishers[1].n_books, 1) self.assertEqual( sorted(p.name for p in publishers), From ca3927dfb946cf7de2fbd1d85ce9433f02069eba Mon Sep 17 00:00:00 2001 From: Matt Lauber Date: Mon, 21 Apr 2014 10:48:33 -0400 Subject: [PATCH 281/367] [1.4.x] Corrected the section identifier for MySQL unicode reference. Backport of b2514c02e1 from master --- docs/ref/unicode.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/unicode.txt b/docs/ref/unicode.txt index 1286dcfdd039..4d644cd68a73 100644 --- a/docs/ref/unicode.txt +++ b/docs/ref/unicode.txt @@ -17,7 +17,7 @@ data. Normally, this means giving it an encoding of UTF-8 or UTF-16. If you use a more restrictive encoding -- for example, latin1 (iso8859-1) -- you won't be able to store certain characters in the database, and information will be lost. -* MySQL users, refer to the `MySQL manual`_ (section 9.1.3.2 for MySQL 5.1) +* MySQL users, refer to the `MySQL manual`_ (section 10.1.3.2 for MySQL 5.1) for details on how to set or alter the database character set encoding. * PostgreSQL users, refer to the `PostgreSQL manual`_ (section 21.2.2 in From c1a8c420fe4b27fb2caf5e46d23b5712fc0ac535 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sun, 20 Apr 2014 13:35:24 -0400 Subject: [PATCH 282/367] [1.4.x] Fixed a remote code execution vulnerabilty in URL reversing. Thanks Benjamin Bach for the report and initial patch. This is a security fix; disclosure to follow shortly. Backport of 8b93b31487d6d3b0fcbbd0498991ea0db9088054 from master --- django/core/urlresolvers.py | 22 +++++++++++++++++- .../urlpatterns_reverse/nonimported_module.py | 3 +++ .../urlpatterns_reverse/tests.py | 23 ++++++++++++++++++- .../urlpatterns_reverse/urls.py | 1 + .../urlpatterns_reverse/views.py | 4 ++++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/regressiontests/urlpatterns_reverse/nonimported_module.py diff --git a/django/core/urlresolvers.py b/django/core/urlresolvers.py index 1497d43e9154..6a2aec7050d5 100644 --- a/django/core/urlresolvers.py +++ b/django/core/urlresolvers.py @@ -230,6 +230,10 @@ def __init__(self, regex, urlconf_name, default_kwargs=None, app_name=None, name self._reverse_dict = {} self._namespace_dict = {} self._app_dict = {} + # set of dotted paths to all functions and classes that are used in + # urlpatterns + self._callback_strs = set() + self._populated = False def __repr__(self): return smart_str(u'<%s %s (%s:%s) %s>' % (self.__class__.__name__, self.urlconf_name, self.app_name, self.namespace, self.regex.pattern)) @@ -240,6 +244,15 @@ def _populate(self): apps = {} language_code = get_language() for pattern in reversed(self.url_patterns): + if hasattr(pattern, '_callback_str'): + self._callback_strs.add(pattern._callback_str) + elif hasattr(pattern, '_callback'): + callback = pattern._callback + if not hasattr(callback, '__name__'): + lookup_str = callback.__module__ + "." + callback.__class__.__name__ + else: + lookup_str = callback.__module__ + "." + callback.__name__ + self._callback_strs.add(lookup_str) p_pattern = pattern.regex.pattern if p_pattern.startswith('^'): p_pattern = p_pattern[1:] @@ -260,6 +273,7 @@ def _populate(self): namespaces[namespace] = (p_pattern + prefix, sub_pattern) for app_name, namespace_list in pattern.app_dict.items(): apps.setdefault(app_name, []).extend(namespace_list) + self._callback_strs.update(pattern._callback_strs) else: bits = normalize(p_pattern) lookups.appendlist(pattern.callback, (bits, p_pattern, pattern.default_args)) @@ -268,6 +282,7 @@ def _populate(self): self._reverse_dict[language_code] = lookups self._namespace_dict[language_code] = namespaces self._app_dict[language_code] = apps + self._populated = True @property def reverse_dict(self): @@ -356,8 +371,13 @@ def reverse(self, lookup_view, *args, **kwargs): def _reverse_with_prefix(self, lookup_view, _prefix, *args, **kwargs): if args and kwargs: raise ValueError("Don't mix *args and **kwargs in call to reverse()!") + + if not self._populated: + self._populate() + try: - lookup_view = get_callable(lookup_view, True) + if lookup_view in self._callback_strs: + lookup_view = get_callable(lookup_view, True) except (ImportError, AttributeError), e: raise NoReverseMatch("Error importing '%s': %s." % (lookup_view, e)) possibilities = self.reverse_dict.getlist(lookup_view) diff --git a/tests/regressiontests/urlpatterns_reverse/nonimported_module.py b/tests/regressiontests/urlpatterns_reverse/nonimported_module.py new file mode 100644 index 000000000000..df046333d3ac --- /dev/null +++ b/tests/regressiontests/urlpatterns_reverse/nonimported_module.py @@ -0,0 +1,3 @@ +def view(request): + """Stub view""" + pass diff --git a/tests/regressiontests/urlpatterns_reverse/tests.py b/tests/regressiontests/urlpatterns_reverse/tests.py index a1c92449180c..0ea732b8abda 100644 --- a/tests/regressiontests/urlpatterns_reverse/tests.py +++ b/tests/regressiontests/urlpatterns_reverse/tests.py @@ -1,8 +1,11 @@ +# -*- coding: utf-8 -*- """ Unit tests for reverse URL lookups. """ from __future__ import absolute_import +import sys + from django.conf import settings from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist from django.core.urlresolvers import (reverse, resolve, NoReverseMatch, @@ -267,6 +270,25 @@ def test_redirect_to_url(self): self.assertEqual(res['Location'], '/foo/') res = redirect('http://example.com/') self.assertEqual(res['Location'], 'http://example.com/') + # Assert that we can redirect using UTF-8 strings + res = redirect('/æøå/abc/') + self.assertEqual(res['Location'], '/%C3%A6%C3%B8%C3%A5/abc/') + # Assert that no imports are attempted when dealing with a relative path + # (previously, the below would resolve in a UnicodeEncodeError from __import__ ) + res = redirect('/æøå.abc/') + self.assertEqual(res['Location'], '/%C3%A6%C3%B8%C3%A5.abc/') + res = redirect('os.path') + self.assertEqual(res['Location'], 'os.path') + + def test_no_illegal_imports(self): + # modules that are not listed in urlpatterns should not be importable + redirect("urlpatterns_reverse.nonimported_module.view") + self.assertNotIn("urlpatterns_reverse.nonimported_module", sys.modules) + + def test_reverse_by_path_nested(self): + # Views that are added to urlpatterns using include() should be + # reversable by doted path. + self.assertEqual(reverse('regressiontests.urlpatterns_reverse.views.nested_view'), '/includes/nested_path/') def test_redirect_view_object(self): from .views import absolute_kwargs_view @@ -510,4 +532,3 @@ def test_erroneous_resolve(self): self.assertRaises(ViewDoesNotExist, self.client.get, '/missing_inner/') self.assertRaises(ViewDoesNotExist, self.client.get, '/missing_outer/') self.assertRaises(ViewDoesNotExist, self.client.get, '/uncallable/') - diff --git a/tests/regressiontests/urlpatterns_reverse/urls.py b/tests/regressiontests/urlpatterns_reverse/urls.py index 1d4ae73c676e..2af306b59731 100644 --- a/tests/regressiontests/urlpatterns_reverse/urls.py +++ b/tests/regressiontests/urlpatterns_reverse/urls.py @@ -7,6 +7,7 @@ other_patterns = patterns('', url(r'non_path_include/$', empty_view, name='non_path_include'), + url(r'nested_path/$', 'regressiontests.urlpatterns_reverse.views.nested_view'), ) urlpatterns = patterns('', diff --git a/tests/regressiontests/urlpatterns_reverse/views.py b/tests/regressiontests/urlpatterns_reverse/views.py index f631acf3ec19..84b07087eaeb 100644 --- a/tests/regressiontests/urlpatterns_reverse/views.py +++ b/tests/regressiontests/urlpatterns_reverse/views.py @@ -16,6 +16,10 @@ def absolute_kwargs_view(request, arg1=1, arg2=2): def defaults_view(request, arg1, arg2): pass +def nested_view(request): + pass + + def erroneous_view(request): import non_existent From 1170f285ddd6a94a65f911a27788ba49ca08c0b0 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Sun, 20 Apr 2014 16:32:09 -0400 Subject: [PATCH 283/367] [1.4.x] Prevented leaking the CSRF token through caching. This is a security fix. Disclosure will follow shortly. Backport of c083e3815aec23b99833da710eea574e6f2e8566 from master --- django/middleware/cache.py | 10 +++++++++- tests/regressiontests/cache/tests.py | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/django/middleware/cache.py b/django/middleware/cache.py index 34bf0ca4a46c..760ba4e62da3 100644 --- a/django/middleware/cache.py +++ b/django/middleware/cache.py @@ -50,7 +50,8 @@ from django.conf import settings from django.core.cache import get_cache, DEFAULT_CACHE_ALIAS -from django.utils.cache import get_cache_key, learn_cache_key, patch_response_headers, get_max_age +from django.utils.cache import (get_cache_key, get_max_age, has_vary_header, + learn_cache_key, patch_response_headers) class UpdateCacheMiddleware(object): @@ -93,8 +94,15 @@ def process_response(self, request, response): if not self._should_update_cache(request, response): # We don't need to update the cache, just return. return response + if not response.status_code == 200: return response + + # Don't cache responses that set a user-specific (and maybe security + # sensitive) cookie in response to a cookie-less request. + if not request.COOKIES and response.cookies and has_vary_header(response, 'Cookie'): + return response + # Try to get the timeout from the "max-age" section of the "Cache- # Control" header before reverting to using the default cache_timeout # length. diff --git a/tests/regressiontests/cache/tests.py b/tests/regressiontests/cache/tests.py index bd29cde53300..b0be2599ef62 100644 --- a/tests/regressiontests/cache/tests.py +++ b/tests/regressiontests/cache/tests.py @@ -17,10 +17,12 @@ from django.core.cache import get_cache, DEFAULT_CACHE_ALIAS from django.core.cache.backends.base import (CacheKeyWarning, InvalidCacheBackendError) +from django.core.context_processors import csrf from django.db import router from django.http import HttpResponse, HttpRequest, QueryDict from django.middleware.cache import (FetchFromCacheMiddleware, UpdateCacheMiddleware, CacheMiddleware) +from django.middleware.csrf import CsrfViewMiddleware from django.template import Template from django.template.response import TemplateResponse from django.test import TestCase, TransactionTestCase, RequestFactory @@ -1418,6 +1420,10 @@ def hello_world_view(request, value): return HttpResponse('Hello World %s' % value) +def csrf_view(request): + return HttpResponse(csrf(request)['csrf_token']) + + class CacheMiddlewareTest(TestCase): def setUp(self): @@ -1635,6 +1641,27 @@ def test_view_decorator(self): response = other_with_timeout_view(request, '18') self.assertEqual(response.content, 'Hello World 18') + def test_sensitive_cookie_not_cached(self): + """ + Django must prevent caching of responses that set a user-specific (and + maybe security sensitive) cookie in response to a cookie-less request. + """ + csrf_middleware = CsrfViewMiddleware() + cache_middleware = CacheMiddleware() + + request = self.factory.get('/view/') + self.assertIsNone(cache_middleware.process_request(request)) + + csrf_middleware.process_view(request, csrf_view, (), {}) + + response = csrf_view(request) + + response = csrf_middleware.process_response(request, response) + response = cache_middleware.process_response(request, response) + + # Inserting a CSRF cookie in a cookie-less request prevented caching. + self.assertIsNone(cache_middleware.process_request(request)) + CacheMiddlewareTest = override_settings( CACHE_MIDDLEWARE_ALIAS='other', CACHE_MIDDLEWARE_KEY_PREFIX='middlewareprefix', From aa80f498de6d687e613860933ac58433ab71ea4b Mon Sep 17 00:00:00 2001 From: Erik Romijn Date: Sun, 20 Apr 2014 16:32:48 -0400 Subject: [PATCH 284/367] [1.4.x] Fixed queries that may return unexpected results on MySQL due to typecasting. This is a security fix. Disclosure will follow shortly. Backport of 75c0d4ea3ae48970f788c482ee0bd6b29a7f1307 from master --- django/db/models/fields/__init__.py | 16 +++- docs/howto/custom-model-fields.txt | 10 +++ docs/ref/databases.txt | 16 ++++ docs/ref/models/querysets.txt | 10 +++ docs/topics/db/sql.txt | 10 +++ tests/regressiontests/model_fields/tests.py | 94 ++++++++++++++++++++- 6 files changed, 154 insertions(+), 2 deletions(-) diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index 527a3c0b0e9a..690a671452eb 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -911,6 +911,12 @@ def __init__(self, verbose_name=None, name=None, path='', match=None, kwargs['max_length'] = kwargs.get('max_length', 100) Field.__init__(self, verbose_name, name, **kwargs) + def get_prep_value(self, value): + value = super(FilePathField, self).get_prep_value(value) + if value is None: + return None + return smart_unicode(value) + def formfield(self, **kwargs): defaults = { 'path': self.path, @@ -1010,6 +1016,12 @@ def __init__(self, *args, **kwargs): kwargs['max_length'] = 15 Field.__init__(self, *args, **kwargs) + def get_prep_value(self, value): + value = super(IPAddressField, self).get_prep_value(value) + if value is None: + return None + return smart_unicode(value) + def get_internal_type(self): return "IPAddressField" @@ -1047,12 +1059,14 @@ def get_db_prep_value(self, value, connection, prepared=False): return value or None def get_prep_value(self, value): + if value is None: + return value if value and ':' in value: try: return clean_ipv6_address(value, self.unpack_ipv4) except exceptions.ValidationError: pass - return value + return smart_unicode(value) def formfield(self, **kwargs): defaults = {'form_class': forms.GenericIPAddressField} diff --git a/docs/howto/custom-model-fields.txt b/docs/howto/custom-model-fields.txt index daaede8e15a0..fcbda032b76e 100644 --- a/docs/howto/custom-model-fields.txt +++ b/docs/howto/custom-model-fields.txt @@ -482,6 +482,16 @@ For example:: return ''.join([''.join(l) for l in (value.north, value.east, value.south, value.west)]) +.. warning:: + + If your custom field uses the ``CHAR``, ``VARCHAR`` or ``TEXT`` + types for MySQL, you must make sure that :meth:`.get_prep_value` + always returns a string type. MySQL performs flexible and unexpected + matching when a query is performed on these types and the provided + value is an integer, which can cause queries to include unexpected + objects in their results. This problem cannot occur if you always + return a string type from :meth:`.get_prep_value`. + Converting query values to database values ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index 4c18658304b5..269197946ea9 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -432,6 +432,22 @@ MySQL does not support the ``NOWAIT`` option to the ``SELECT ... FOR UPDATE`` statement. If ``select_for_update()`` is used with ``nowait=True`` then a ``DatabaseError`` will be raised. +Automatic typecasting can cause unexpected results +-------------------------------------------------- + +When performing a query on a string type, but with an integer value, MySQL will +coerce the types of all values in the table to an integer before performing the +comparison. If your table contains the values ``'abc'``, ``'def'`` and you +query for ``WHERE mycolumn=0``, both rows will match. Similarly, ``WHERE mycolumn=1`` +will match the value ``'abc1'``. Therefore, string type fields included in Django +will always cast the value to a string before using it in a query. + +If you implement custom model fields that inherit from :class:`~django.db.models.Field` +directly, are overriding :meth:`~django.db.models.Field.get_prep_value`, or use +:meth:`extra() ` or +:meth:`raw() `, you should ensure that you +perform the appropriate typecasting. + .. _sqlite-notes: SQLite notes diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 022a251e5c45..2decddbc28a2 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -1041,6 +1041,16 @@ of the arguments is required, but you should use at least one of them. Entry.objects.extra(where=['headline=%s'], params=['Lennon']) +.. warning:: + + If you are performing queries on MySQL, note that MySQL's silent type coercion + may cause unexpected results when mixing types. If you query on a string + type column, but with an integer value, MySQL will coerce the types of all values + in the table to an integer before performing the comparison. For example, if your + table contains the values ``'abc'``, ``'def'`` and you query for ``WHERE mycolumn=0``, + both rows will match. To prevent this, perform the correct typecasting + before using the value in a query. + defer ~~~~~ diff --git a/docs/topics/db/sql.txt b/docs/topics/db/sql.txt index 80038e547f78..d387ad55201a 100644 --- a/docs/topics/db/sql.txt +++ b/docs/topics/db/sql.txt @@ -69,6 +69,16 @@ options that make it very powerful. database, but does nothing to enforce that. If the query does not return rows, a (possibly cryptic) error will result. +.. warning:: + + If you are performing queries on MySQL, note that MySQL's silent type coercion + may cause unexpected results when mixing types. If you query on a string + type column, but with an integer value, MySQL will coerce the types of all values + in the table to an integer before performing the comparison. For example, if your + table contains the values ``'abc'``, ``'def'`` and you query for ``WHERE mycolumn=0``, + both rows will match. To prevent this, perform the correct typecasting + before using the value in a query. + Mapping query fields to model fields ------------------------------------ diff --git a/tests/regressiontests/model_fields/tests.py b/tests/regressiontests/model_fields/tests.py index a71ea77ea3c0..30f0e7d09ec9 100644 --- a/tests/regressiontests/model_fields/tests.py +++ b/tests/regressiontests/model_fields/tests.py @@ -6,8 +6,15 @@ from django import test from django import forms from django.core.exceptions import ValidationError +from django.db.models.fields import ( + AutoField, BigIntegerField, BooleanField, CharField, + CommaSeparatedIntegerField, DateField, DateTimeField, DecimalField, + EmailField, FilePathField, FloatField, IntegerField, IPAddressField, + GenericIPAddressField, NullBooleanField, PositiveIntegerField, + PositiveSmallIntegerField, SlugField, SmallIntegerField, TextField, + TimeField, URLField) from django.db import models -from django.db.models.fields.files import FieldFile +from django.db.models.fields.files import FileField, ImageField, FieldFile from django.utils import unittest from .models import (Foo, Bar, Whiz, BigD, BigS, Image, BigInt, Post, @@ -373,3 +380,88 @@ def test_changed(self): field = d._meta.get_field('myfile') field.save_form_data(d, 'else.txt') self.assertEqual(d.myfile, 'else.txt') + + +class PrepValueTest(test.TestCase): + def test_AutoField(self): + self.assertIsInstance(AutoField(primary_key=True).get_prep_value(1), int) + + def test_BigIntegerField(self): + self.assertIsInstance(BigIntegerField().get_prep_value(long(9999999999999999999)), long) + + def test_BooleanField(self): + self.assertIsInstance(BooleanField().get_prep_value(True), bool) + + def test_CharField(self): + self.assertIsInstance(CharField().get_prep_value(''), str) + self.assertIsInstance(CharField().get_prep_value(0), unicode) + + def test_CommaSeparatedIntegerField(self): + self.assertIsInstance(CommaSeparatedIntegerField().get_prep_value('1,2'), str) + self.assertIsInstance(CommaSeparatedIntegerField().get_prep_value(0), unicode) + + def test_DateField(self): + self.assertIsInstance(DateField().get_prep_value(datetime.date.today()), datetime.date) + + def test_DateTimeField(self): + self.assertIsInstance(DateTimeField().get_prep_value(datetime.datetime.now()), datetime.datetime) + + def test_DecimalField(self): + self.assertIsInstance(DecimalField().get_prep_value(Decimal('1.2')), Decimal) + + def test_EmailField(self): + self.assertIsInstance(EmailField().get_prep_value('mailbox@domain.com'), str) + + def test_FileField(self): + self.assertIsInstance(FileField().get_prep_value('filename.ext'), unicode) + self.assertIsInstance(FileField().get_prep_value(0), unicode) + + def test_FilePathField(self): + self.assertIsInstance(FilePathField().get_prep_value('tests.py'), unicode) + self.assertIsInstance(FilePathField().get_prep_value(0), unicode) + + def test_FloatField(self): + self.assertIsInstance(FloatField().get_prep_value(1.2), float) + + def test_ImageField(self): + self.assertIsInstance(ImageField().get_prep_value('filename.ext'), unicode) + + def test_IntegerField(self): + self.assertIsInstance(IntegerField().get_prep_value(1), int) + + def test_IPAddressField(self): + self.assertIsInstance(IPAddressField().get_prep_value('127.0.0.1'), unicode) + self.assertIsInstance(IPAddressField().get_prep_value(0), unicode) + + def test_GenericIPAddressField(self): + self.assertIsInstance(GenericIPAddressField().get_prep_value('127.0.0.1'), unicode) + self.assertIsInstance(GenericIPAddressField().get_prep_value(0), unicode) + + def test_NullBooleanField(self): + self.assertIsInstance(NullBooleanField().get_prep_value(True), bool) + + def test_PositiveIntegerField(self): + self.assertIsInstance(PositiveIntegerField().get_prep_value(1), int) + + def test_PositiveSmallIntegerField(self): + self.assertIsInstance(PositiveSmallIntegerField().get_prep_value(1), int) + + def test_SlugField(self): + self.assertIsInstance(SlugField().get_prep_value('slug'), str) + self.assertIsInstance(SlugField().get_prep_value(0), unicode) + + def test_SmallIntegerField(self): + self.assertIsInstance(SmallIntegerField().get_prep_value(1), int) + + def test_TextField(self): + self.assertIsInstance(TextField().get_prep_value('Abc'), str) + self.assertIsInstance(TextField().get_prep_value(0), unicode) + + def test_TimeField(self): + self.assertIsInstance( + TimeField().get_prep_value(datetime.datetime.now().time()), + datetime.time) + + def test_URLField(self): + self.assertIsInstance(URLField().get_prep_value('http://domain.com'), str) + From 80109083136d7d633810d765b74e4c3018434010 Mon Sep 17 00:00:00 2001 From: Erik Romijn Date: Mon, 21 Apr 2014 12:53:34 +0200 Subject: [PATCH 285/367] [1.4.x] Added information on resolved security issues to release notes. Backport of c07f3e60c2d455e36ba4ac339d4283d32bbc3814 from master --- docs/releases/1.4.11.txt | 108 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 3 deletions(-) diff --git a/docs/releases/1.4.11.txt b/docs/releases/1.4.11.txt index b42b20aa7b02..24194540d579 100644 --- a/docs/releases/1.4.11.txt +++ b/docs/releases/1.4.11.txt @@ -2,7 +2,109 @@ Django 1.4.11 release notes =========================== -*Under development* +*April 21, 2014* -Django's vendored version of six, :mod:`django.utils.six`, has been upgraded to -the latest release (1.6.1). +Django 1.4.11 fixes three security issues in 1.4.10. Additionally, +Django's vendored version of six, :mod:`django.utils.six`, has been +upgraded to the latest release (1.6.1). + +Unexpected code execution using ``reverse()`` +============================================= + +Django's URL handling is based on a mapping of regex patterns +(representing the URLs) to callable views, and Django's own processing +consists of matching a requested URL against those patterns to +determine the appropriate view to invoke. + +Django also provides a convenience function -- +:func:`~django.core.urlresolvers.reverse` -- which performs this process +in the opposite direction. The ``reverse()`` function takes +information about a view and returns a URL which would invoke that +view. Use of ``reverse()`` is encouraged for application developers, +as the output of ``reverse()`` is always based on the current URL +patterns, meaning developers do not need to change other code when +making changes to URLs. + +One argument signature for ``reverse()`` is to pass a dotted Python +path to the desired view. In this situation, Django will import the +module indicated by that dotted path as part of generating the +resulting URL. If such a module has import-time side effects, those +side effects will occur. + +Thus it is possible for an attacker to cause unexpected code +execution, given the following conditions: + +1. One or more views are present which construct a URL based on user + input (commonly, a "next" parameter in a querystring indicating + where to redirect upon successful completion of an action). + +2. One or more modules are known to an attacker to exist on the + server's Python import path, which perform code execution with side + effects on importing. + +To remedy this, ``reverse()`` will now only accept and import dotted +paths based on the view-containing modules listed in the project's :doc:`URL +pattern configuration `, so as to ensure that only modules +the developer intended to be imported in this fashion can or will be imported. + +Caching of anonymous pages could reveal CSRF token +================================================== + +Django includes both a :doc:`caching framework ` and a system +for :doc:`preventing cross-site request forgery (CSRF) attacks +`. The CSRF-protection system is based on a random nonce +sent to the client in a cookie which must be sent by the client on future +requests and, in forms, a hidden value which must be submitted back with the +form. + +The caching framework includes an option to cache responses to +anonymous (i.e., unauthenticated) clients. + +When the first anonymous request to a given page is by a client which +did not have a CSRF cookie, the cache framework will also cache the +CSRF cookie and serve the same nonce to other anonymous clients who +do not have a CSRF cookie. This can allow an attacker to obtain a +valid CSRF cookie value and perform attacks which bypass the check for +the cookie. + +To remedy this, the caching framework will no longer cache such +responses. The heuristic for this will be: + +1. If the incoming request did not submit any cookies, and + +2. If the response did send one or more cookies, and + +3. If the ``Vary: Cookie`` header is set on the response, then the + response will not be cached. + +MySQL typecasting +================= + +The MySQL database is known to "typecast" on certain queries; for +example, when querying a table which contains string values, but using +a query which filters based on an integer value, MySQL will first +silently coerce the strings to integers and return a result based on that. + +If a query is performed without first converting values to the +appropriate type, this can produce unexpected results, similar to what +would occur if the query itself had been manipulated. + +Django's model field classes are aware of their own types and most +such classes perform explicit conversion of query arguments to the +correct database-level type before querying. However, three model +field classes did not correctly convert their arguments: + +* :class:`~django.db.models.FilePathField` +* :class:`~django.db.models.GenericIPAddressField` +* :class:`~django.db.models.IPAddressField` + +These three fields have been updated to convert their arguments to the +correct types before querying. + +Additionally, developers of custom model fields are now warned via +documentation to ensure their custom field classes will perform +appropriate type conversions, and users of the :meth:`raw() +` and :meth:`extra() +` query methods -- which allow the +developer to supply raw SQL or SQL fragments -- will be advised to ensure they +perform appropriate manual type conversions prior to executing queries. From 194159ba443645947e1a86bfd12e28dc78022521 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Mon, 21 Apr 2014 17:38:26 -0500 Subject: [PATCH 286/367] [1.4.x] Bump version numbers for 1.4.11 security release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 6ec73911af02..b4ce869f7511 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 10, 'final', 0) +VERSION = (1, 4, 11, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 6e25feebb79e..002f4750b155 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.10' +version = '1.4.11' # The full version, including alpha/beta/rc tags. -release = '1.4.10' +release = '1.4.11' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index da5f86a8208a..e71f0b583d40 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.10.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.11.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 1edb163592a21975d69aaca3215678f192421b71 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 22 Apr 2014 11:50:20 -0400 Subject: [PATCH 287/367] [1.4.x] Post release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index b4ce869f7511..ab2f4be55e85 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 11, 'final', 0) +VERSION = (1, 4, 12, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From b91c385e324f1cb94d20e2ad146372c259d51d3b Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 23 Apr 2014 09:22:02 -0400 Subject: [PATCH 288/367] [1.4.x] Fixed #22486 -- Restored the ability to reverse views created using functools.partial. Regression in 8b93b31. Thanks rcoup for the report. Backport of 3c06b2f2a3 from master --- django/core/urlresolvers.py | 4 ++++ docs/releases/1.4.12.txt | 14 ++++++++++++++ docs/releases/index.txt | 1 + tests/regressiontests/urlpatterns_reverse/urls.py | 6 +++++- tests/regressiontests/urlpatterns_reverse/views.py | 10 ++++++++++ 5 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 docs/releases/1.4.12.txt diff --git a/django/core/urlresolvers.py b/django/core/urlresolvers.py index 6a2aec7050d5..6d75cb7cae6b 100644 --- a/django/core/urlresolvers.py +++ b/django/core/urlresolvers.py @@ -7,6 +7,7 @@ (view_function, function_args, function_kwargs) """ +import functools import re from threading import local @@ -248,6 +249,9 @@ def _populate(self): self._callback_strs.add(pattern._callback_str) elif hasattr(pattern, '_callback'): callback = pattern._callback + if isinstance(callback, functools.partial): + callback = callback.func + if not hasattr(callback, '__name__'): lookup_str = callback.__module__ + "." + callback.__class__.__name__ else: diff --git a/docs/releases/1.4.12.txt b/docs/releases/1.4.12.txt new file mode 100644 index 000000000000..561125a2d068 --- /dev/null +++ b/docs/releases/1.4.12.txt @@ -0,0 +1,14 @@ +=========================== +Django 1.4.12 release notes +=========================== + +*Under development* + +Django 1.4.12 fixes a regression in the 1.4.11 security release. + +Bugfixes +======== + +* Restored the ability to :meth:`~django.core.urlresolvers.reverse` views + created using :func:`functools.partial()` + (`#22486 `_) diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 81ac94f26849..d32c6a6ec541 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.12 1.4.11 1.4.10 1.4.9 diff --git a/tests/regressiontests/urlpatterns_reverse/urls.py b/tests/regressiontests/urlpatterns_reverse/urls.py index 2af306b59731..7aae7c469140 100644 --- a/tests/regressiontests/urlpatterns_reverse/urls.py +++ b/tests/regressiontests/urlpatterns_reverse/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import patterns, url, include -from .views import empty_view, absolute_kwargs_view +from .views import empty_view, empty_view_partial, empty_view_wrapped, absolute_kwargs_view other_patterns = patterns('', @@ -56,6 +56,10 @@ # This is non-reversible, but we shouldn't blow up when parsing it. url(r'^(?:foo|bar)(\w+)/$', empty_view, name="disjunction"), + # Partials should be fine. + url(r'^partial/', empty_view_partial, name="partial"), + url(r'^partial_wrapped/', empty_view_wrapped, name="partial_wrapped"), + # Regression views for #9038. See tests for more details url(r'arg_view/$', 'kwargs_view'), url(r'arg_view/(?P\d+)/$', 'kwargs_view'), diff --git a/tests/regressiontests/urlpatterns_reverse/views.py b/tests/regressiontests/urlpatterns_reverse/views.py index 84b07087eaeb..de09552208f4 100644 --- a/tests/regressiontests/urlpatterns_reverse/views.py +++ b/tests/regressiontests/urlpatterns_reverse/views.py @@ -1,3 +1,5 @@ +from functools import partial, update_wrapper + from django.http import HttpResponse from django.views.generic import RedirectView from django.core.urlresolvers import reverse_lazy @@ -40,3 +42,11 @@ def login_required_view(request): def bad_view(request, *args, **kwargs): raise ValueError("I don't think I'm getting good value for this view") + + +empty_view_partial = partial(empty_view, template_name="template.html") + + +empty_view_wrapped = update_wrapper( + partial(empty_view, template_name="template.html"), empty_view, +) From b1b680c8fe3fcfdc934971c9860f0b8644defdc7 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Mon, 28 Apr 2014 15:28:15 -0500 Subject: [PATCH 289/367] [1.4.x] Bump version numbers for 1.4.12 bugfix release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index ab2f4be55e85..55576976af58 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 12, 'alpha', 0) +VERSION = (1, 4, 12, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 002f4750b155..916f618cb278 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.11' +version = '1.4.12' # The full version, including alpha/beta/rc tags. -release = '1.4.11' +release = '1.4.12' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index e71f0b583d40..61ed67d752fd 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.11.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.12.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 48a4729cd75321b5410839abd690207aa00f0169 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 28 Apr 2014 19:03:36 -0400 Subject: [PATCH 290/367] [1.4.x] Post release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 55576976af58..78f776108f28 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 12, 'final', 0) +VERSION = (1, 4, 13, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From e1812617cf534b00230e38af118194d8bf0b48b6 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 28 Apr 2014 19:06:25 -0400 Subject: [PATCH 291/367] [1.4.x] Added dates to release notes of today's release. Backport of 68d264059abb21b96c4fe68bf4d99520268a451c from master --- docs/releases/1.4.12.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.4.12.txt b/docs/releases/1.4.12.txt index 561125a2d068..41752a790fe3 100644 --- a/docs/releases/1.4.12.txt +++ b/docs/releases/1.4.12.txt @@ -2,7 +2,7 @@ Django 1.4.12 release notes =========================== -*Under development* +*April 28, 2014* Django 1.4.12 fixes a regression in the 1.4.11 security release. From 28e23306aa53bbbb8fb87db85f99d970b051026c Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Mon, 12 May 2014 09:46:22 -0400 Subject: [PATCH 292/367] [1.4.x] Dropped fix_IE_for_vary/attach. This is a security fix. Disclosure following shortly. --- django/core/handlers/base.py | 2 -- django/http/utils.py | 54 ----------------------------- tests/regressiontests/utils/http.py | 44 ----------------------- 3 files changed, 100 deletions(-) diff --git a/django/core/handlers/base.py b/django/core/handlers/base.py index a0918bfc41c2..99f81a65be6f 100644 --- a/django/core/handlers/base.py +++ b/django/core/handlers/base.py @@ -14,8 +14,6 @@ class BaseHandler(object): response_fixes = [ http.fix_location_header, http.conditional_content_removal, - http.fix_IE_for_attach, - http.fix_IE_for_vary, ] def __init__(self): diff --git a/django/http/utils.py b/django/http/utils.py index 01808648ba41..f98ca93a37a6 100644 --- a/django/http/utils.py +++ b/django/http/utils.py @@ -31,57 +31,3 @@ def conditional_content_removal(request, response): if request.method == 'HEAD': response.content = '' return response - -def fix_IE_for_attach(request, response): - """ - This function will prevent Django from serving a Content-Disposition header - while expecting the browser to cache it (only when the browser is IE). This - leads to IE not allowing the client to download. - """ - useragent = request.META.get('HTTP_USER_AGENT', '').upper() - if 'MSIE' not in useragent and 'CHROMEFRAME' not in useragent: - return response - - offending_headers = ('no-cache', 'no-store') - if response.has_header('Content-Disposition'): - try: - del response['Pragma'] - except KeyError: - pass - if response.has_header('Cache-Control'): - cache_control_values = [value.strip() for value in - response['Cache-Control'].split(',') - if value.strip().lower() not in offending_headers] - - if not len(cache_control_values): - del response['Cache-Control'] - else: - response['Cache-Control'] = ', '.join(cache_control_values) - - return response - -def fix_IE_for_vary(request, response): - """ - This function will fix the bug reported at - http://support.microsoft.com/kb/824847/en-us?spid=8722&sid=global - by clearing the Vary header whenever the mime-type is not safe - enough for Internet Explorer to handle. Poor thing. - """ - useragent = request.META.get('HTTP_USER_AGENT', '').upper() - if 'MSIE' not in useragent and 'CHROMEFRAME' not in useragent: - return response - - # These mime-types that are decreed "Vary-safe" for IE: - safe_mime_types = ('text/html', 'text/plain', 'text/sgml') - - # The first part of the Content-Type field will be the MIME type, - # everything after ';', such as character-set, can be ignored. - mime_type = response.get('Content-Type', '').partition(';')[0] - if mime_type not in safe_mime_types: - try: - del response['Vary'] - except KeyError: - pass - - return response - diff --git a/tests/regressiontests/utils/http.py b/tests/regressiontests/utils/http.py index 16c7daa32c20..9e05a9411884 100644 --- a/tests/regressiontests/utils/http.py +++ b/tests/regressiontests/utils/http.py @@ -56,50 +56,6 @@ def test_urlencode(self): ] self.assertTrue(result in acceptable_results) - def test_fix_IE_for_vary(self): - """ - Regression for #16632. - - `fix_IE_for_vary` shouldn't crash when there's no Content-Type header. - """ - - # functions to generate responses - def response_with_unsafe_content_type(): - r = HttpResponse(content_type="text/unsafe") - r['Vary'] = 'Cookie' - return r - - def no_content_response_with_unsafe_content_type(): - # 'Content-Type' always defaulted, so delete it - r = response_with_unsafe_content_type() - del r['Content-Type'] - return r - - # request with & without IE user agent - rf = RequestFactory() - request = rf.get('/') - ie_request = rf.get('/', HTTP_USER_AGENT='MSIE') - - # not IE, unsafe_content_type - response = response_with_unsafe_content_type() - utils.fix_IE_for_vary(request, response) - self.assertTrue('Vary' in response) - - # IE, unsafe_content_type - response = response_with_unsafe_content_type() - utils.fix_IE_for_vary(ie_request, response) - self.assertFalse('Vary' in response) - - # not IE, no_content - response = no_content_response_with_unsafe_content_type() - utils.fix_IE_for_vary(request, response) - self.assertTrue('Vary' in response) - - # IE, no_content - response = no_content_response_with_unsafe_content_type() - utils.fix_IE_for_vary(ie_request, response) - self.assertFalse('Vary' in response) - def test_base36(self): # reciprocity works for n in [0, 1, 1000, 1000000, sys.maxint]: From 7feb54bbae3f637ab3c4dd4831d4385964f574df Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 12 May 2014 09:46:40 -0400 Subject: [PATCH 293/367] [1.4.x] Added additional checks in is_safe_url to account for flexible parsing. This is a security fix. Disclosure following shortly. --- django/contrib/auth/tests/views.py | 12 ++++++++---- django/utils/http.py | 12 ++++++++++++ tests/regressiontests/utils/http.py | 30 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py index 6d7029bae850..2b72cd4da53f 100644 --- a/django/contrib/auth/tests/views.py +++ b/django/contrib/auth/tests/views.py @@ -307,8 +307,10 @@ def test_security_check(self, password='password'): # Those URLs should not pass the security check for bad_url in ('http://example.com', + 'http:///example.com', 'https://example.com', 'ftp://exampel.com', + '///example.com', '//example.com', 'javascript:alert("XSS")'): @@ -330,8 +332,8 @@ def test_security_check(self, password='password'): '/view/?param=https://example.com', '/view?param=ftp://exampel.com', 'view/?param=//example.com', - 'https:///', - 'HTTPS:///', + 'https://testserver/', + 'HTTPS://testserver/', '//testserver/', '/url%20with%20spaces/'): # see ticket #12534 safe_url = '%(url)s?%(next)s=%(good_url)s' % { @@ -467,8 +469,10 @@ def test_security_check(self, password='password'): # Those URLs should not pass the security check for bad_url in ('http://example.com', + 'http:///example.com', 'https://example.com', 'ftp://exampel.com', + '///example.com', '//example.com', 'javascript:alert("XSS")'): nasty_url = '%(url)s?%(next)s=%(bad_url)s' % { @@ -488,8 +492,8 @@ def test_security_check(self, password='password'): '/view/?param=https://example.com', '/view?param=ftp://exampel.com', 'view/?param=//example.com', - 'https:///', - 'HTTPS:///', + 'https://testserver/', + 'HTTPS://testserver/', '//testserver/', '/url%20with%20spaces/'): # see ticket #12534 safe_url = '%(url)s?%(next)s=%(good_url)s' % { diff --git a/django/utils/http.py b/django/utils/http.py index 21c84dc8214a..2d404890b53d 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -234,6 +234,18 @@ def is_safe_url(url, host=None): """ if not url: return False + # Chrome treats \ completely as / + url = url.replace('\\', '/') + # Chrome considers any URL with more than two slashes to be absolute, but + # urlaprse is not so flexible. Treat any url with three slashes as unsafe. + if url.startswith('///'): + return False url_info = urlparse.urlparse(url) + # Forbid URLs like http:///example.com - with a scheme, but without a hostname. + # In that URL, example.com is not the hostname but, a path component. However, + # Chrome will still consider example.com to be the hostname, so we must not + # allow this syntax. + if not url_info[1] and url_info[0]: + return False return (not url_info[1] or url_info[1] == host) and \ (not url_info[0] or url_info[0] in ['http', 'https']) diff --git a/tests/regressiontests/utils/http.py b/tests/regressiontests/utils/http.py index 9e05a9411884..802b3fa88d56 100644 --- a/tests/regressiontests/utils/http.py +++ b/tests/regressiontests/utils/http.py @@ -78,3 +78,33 @@ def test_base36(self): for n, b36 in [(0, '0'), (1, '1'), (42, '16'), (818469960, 'django')]: self.assertEqual(http.int_to_base36(n), b36) self.assertEqual(http.base36_to_int(b36), n) + + def test_is_safe_url(self): + for bad_url in ('http://example.com', + 'http:///example.com', + 'https://example.com', + 'ftp://exampel.com', + r'\\example.com', + r'\\\example.com', + r'/\\/example.com', + r'\\\example.com', + r'\\example.com', + r'\\//example.com', + r'/\/example.com', + r'\/example.com', + r'/\example.com', + 'http:///example.com', + 'http:/\//example.com', + 'http:\/example.com', + 'http:/\example.com', + 'javascript:alert("XSS")'): + self.assertFalse(http.is_safe_url(bad_url, host='testserver'), "%s should be blocked" % bad_url) + for good_url in ('/view/?param=http://example.com', + '/view/?param=https://example.com', + '/view?param=ftp://exampel.com', + 'view/?param=//example.com', + 'https://testserver/', + 'HTTPS://testserver/', + '//testserver/', + '/url%20with%20spaces/'): + self.assertTrue(http.is_safe_url(good_url, host='testserver'), "%s should be allowed" % good_url) From fe5b3e36a2734581add6cfc11f55eb667887f092 Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Wed, 14 May 2014 18:07:32 +0200 Subject: [PATCH 294/367] Added release notes for 1.4.13. --- docs/releases/1.4.13.txt | 47 ++++++++++++++++++++++++++++++++++++++++ docs/releases/index.txt | 1 + 2 files changed, 48 insertions(+) create mode 100644 docs/releases/1.4.13.txt diff --git a/docs/releases/1.4.13.txt b/docs/releases/1.4.13.txt new file mode 100644 index 000000000000..bcbe460af5b5 --- /dev/null +++ b/docs/releases/1.4.13.txt @@ -0,0 +1,47 @@ +========================== +Django 1.4.13 release notes +========================== + +*May 13, 2014* + +Django 1.4.13 fixes two security issues in 1.4.12. + + +Caches may incorrectly be allowed to store and serve private data +================================================================= +In certain situations, Django may allow caches to store private data +related to a particular session and then serve that data to requests +with a different session, or no session at all. This can both lead to +information disclosure, and can be a vector for cache poisoning. + +When using Django sessions, Django will set a ``Vary: Cookie`` header to +ensure caches do not serve cached data to requests from other sessions. +However, older versions of Internet Explorer (most likely only Internet +Explorer 6, and Internet Explorer 7 if run on Windows XP or Windows Server +2003) are unable to handle the ``Vary`` header in combination with many content +types. Therefore, Django would remove the header if the request was made by +Internet Explorer. + +To remedy this, the special behaviour for these older Internet Explorer versions +has been removed, and the ``Vary`` header is no longer stripped from the response. +In addition, modifications to the ``Cache-Control`` header for all Internet Explorer +requests with a ``Content-Disposition`` header, have also been removed as they +were found to have similar issues. + + +Malformed redirect URLs from user input not correctly validated +=============================================================== +The validation for redirects did not correctly validate some malformed URLs, +which are accepted by some browsers. This allows a user to be redirected to +an unsafe URL unexpectedly. + +Django relies on user input in some cases (e.g. +:func:`django.contrib.auth.views.login`, ``django.contrib.comments``, and +:doc:`i18n `) to redirect the user to an "on success" URL. +The security checks for these redirects (namely +``django.util.http.is_safe_url()``) did not correctly validate some malformed +URLs, such as `http:\\\\\\djangoproject.com`, which are accepted by some browsers +with more liberal URL parsing. + +To remedy this, the validation in ``is_safe_url()`` has been tightened to be able +to handle and correctly validate these malformed URLs. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index d32c6a6ec541..d699069610d5 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.13 1.4.12 1.4.11 1.4.10 From 53b98b5a7cd7febc97add511c36d91285e03d86a Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Wed, 14 May 2014 18:09:51 +0200 Subject: [PATCH 295/367] Bumped version numbers for release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 78f776108f28..1c3e27cf69c4 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 13, 'alpha', 0) +VERSION = (1, 4, 13, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 916f618cb278..e933373b8f66 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.12' +version = '1.4.13' # The full version, including alpha/beta/rc tags. -release = '1.4.12' +release = '1.4.13' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index 61ed67d752fd..4bc36b056329 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.12.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.13.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 37d6821d35074ccbf1b0deeeb03561b07dbafd45 Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Wed, 14 May 2014 18:24:02 +0200 Subject: [PATCH 296/367] Bumped version numbers post-release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 1c3e27cf69c4..bfd4caf5925f 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 13, 'final', 0) +VERSION = (1, 4, 14, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From d39fcff11a152e37fbc9a0fb34216415ef2a03da Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 15 May 2014 07:11:29 -0400 Subject: [PATCH 297/367] [1.4.x] Minor edits to latest release notes. Backport of 860d31ac7a3bdd4b27db8b34b110b3d801ddaf8a from master --- docs/releases/1.4.13.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/releases/1.4.13.txt b/docs/releases/1.4.13.txt index bcbe460af5b5..978f93580cc1 100644 --- a/docs/releases/1.4.13.txt +++ b/docs/releases/1.4.13.txt @@ -1,18 +1,18 @@ -========================== +=========================== Django 1.4.13 release notes -========================== +=========================== -*May 13, 2014* +*May 14, 2014* Django 1.4.13 fixes two security issues in 1.4.12. - Caches may incorrectly be allowed to store and serve private data ================================================================= + In certain situations, Django may allow caches to store private data related to a particular session and then serve that data to requests -with a different session, or no session at all. This can both lead to -information disclosure, and can be a vector for cache poisoning. +with a different session, or no session at all. This can lead to +information disclosure and can be a vector for cache poisoning. When using Django sessions, Django will set a ``Vary: Cookie`` header to ensure caches do not serve cached data to requests from other sessions. @@ -22,15 +22,15 @@ Explorer 6, and Internet Explorer 7 if run on Windows XP or Windows Server types. Therefore, Django would remove the header if the request was made by Internet Explorer. -To remedy this, the special behaviour for these older Internet Explorer versions +To remedy this, the special behavior for these older Internet Explorer versions has been removed, and the ``Vary`` header is no longer stripped from the response. In addition, modifications to the ``Cache-Control`` header for all Internet Explorer -requests with a ``Content-Disposition`` header, have also been removed as they +requests with a ``Content-Disposition`` header have also been removed as they were found to have similar issues. - Malformed redirect URLs from user input not correctly validated =============================================================== + The validation for redirects did not correctly validate some malformed URLs, which are accepted by some browsers. This allows a user to be redirected to an unsafe URL unexpectedly. From d29f3b9e878c10417d66e1542ac52fe2ca242cf8 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 18 Jun 2014 14:35:38 -0400 Subject: [PATCH 298/367] [1.4.x] Fixed #22859 -- Improved crossDomain technique in CSRF example. Thanks flisky for the report. Backport of 0be4d64487 from master --- docs/ref/contrib/csrf.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/ref/contrib/csrf.txt b/docs/ref/contrib/csrf.txt index c6d448811db8..591223e8b57d 100644 --- a/docs/ref/contrib/csrf.txt +++ b/docs/ref/contrib/csrf.txt @@ -190,9 +190,8 @@ jQuery 1.5 and newer in order to replace the `sameOrigin` logic above: return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); } $.ajaxSetup({ - crossDomain: false, // obviates need for sameOrigin test beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type)) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { xhr.setRequestHeader("X-CSRFToken", csrftoken); } } From b44519072e8a0ef56a0ae9e6e4a1fb04273eb0eb Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 8 Jul 2014 15:58:14 -0400 Subject: [PATCH 299/367] [1.4.x] Fixed #13794 -- Fixed to_field usage in BaseInlineFormSet. Thanks sebastien at clarisys.fr for the report and gautier for the patch. Backport of 5e2c4a4bd1 from master --- django/forms/models.py | 8 +++-- .../model_formsets_regress/models.py | 9 +++++ .../model_formsets_regress/tests.py | 36 ++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/django/forms/models.py b/django/forms/models.py index cd8f027070e7..c16faae4abd4 100644 --- a/django/forms/models.py +++ b/django/forms/models.py @@ -712,8 +712,12 @@ def _construct_form(self, i, **kwargs): # Remove the foreign key from the form's data form.data[form.add_prefix(self.fk.name)] = None - # Set the fk value here so that the form can do it's validation. - setattr(form.instance, self.fk.get_attname(), self.instance.pk) + # Set the fk value here so that the form can do its validation. + fk_value = self.instance.pk + if self.fk.rel.field_name != self.fk.rel.to._meta.pk.name: + fk_value = getattr(self.instance, self.fk.rel.field_name) + fk_value = getattr(fk_value, 'pk', fk_value) + setattr(form.instance, self.fk.get_attname(), fk_value) return form @classmethod diff --git a/tests/regressiontests/model_formsets_regress/models.py b/tests/regressiontests/model_formsets_regress/models.py index 189ed8072e0e..2dcf76edac95 100644 --- a/tests/regressiontests/model_formsets_regress/models.py +++ b/tests/regressiontests/model_formsets_regress/models.py @@ -9,6 +9,15 @@ class UserSite(models.Model): user = models.ForeignKey(User, to_field="username") data = models.IntegerField() +class UserProfile(models.Model): + user = models.ForeignKey(User, unique=True, to_field="username") + about = models.TextField() + +class ProfileNetwork(models.Model): + profile = models.ForeignKey(UserProfile, to_field="user") + network = models.IntegerField() + identifier = models.IntegerField() + class Place(models.Model): name = models.CharField(max_length=50) diff --git a/tests/regressiontests/model_formsets_regress/tests.py b/tests/regressiontests/model_formsets_regress/tests.py index d058f1d8bcb9..81a65cd38871 100644 --- a/tests/regressiontests/model_formsets_regress/tests.py +++ b/tests/regressiontests/model_formsets_regress/tests.py @@ -6,7 +6,10 @@ from django.forms.models import modelform_factory, inlineformset_factory, modelformset_factory, BaseModelFormSet from django.test import TestCase -from .models import User, UserSite, Restaurant, Manager, Network, Host +from .models import ( + User, UserSite, UserProfile, ProfileNetwork, Restaurant, Manager, Network, + Host, +) class InlineFormsetTests(TestCase): @@ -153,6 +156,37 @@ def test_formset_over_inherited_model(self): else: self.fail('Errors found on formset:%s' % form_set.errors) + def test_inline_model_with_to_field(self): + """ + #13794 --- An inline model with a to_field of a formset with instance + has working relations. + """ + FormSet = inlineformset_factory(User, UserSite, exclude=('is_superuser',)) + + user = User.objects.create(username="guido", serial=1337) + UserSite.objects.create(user=user, data=10) + formset = FormSet(instance=user) + + # Testing the inline model's relation + self.assertEqual(formset[0].instance.user_id, "guido") + + def test_inline_model_with_to_field_to_rel(self): + """ + #13794 --- An inline model with a to_field to a related field of a + formset with instance has working relations. + """ + FormSet = inlineformset_factory(UserProfile, ProfileNetwork, exclude=[]) + + user = User.objects.create(username="guido", serial=1337, pk=1) + self.assertEqual(user.pk, 1) + profile = UserProfile.objects.create(user=user, about="about", pk=2) + self.assertEqual(profile.pk, 2) + ProfileNetwork.objects.create(profile=profile, network=10, identifier=10) + formset = FormSet(instance=profile) + + # Testing the inline model's relation + self.assertEqual(formset[0].instance.profile_id, 1) + def test_formset_with_none_instance(self): "A formset with instance=None can be created. Regression for #11872" Form = modelform_factory(User) From aa9c45c2e425662d980cca82974da0986fdbe406 Mon Sep 17 00:00:00 2001 From: Ramiro Morales Date: Mon, 14 Jul 2014 21:09:38 -0300 Subject: [PATCH 300/367] [1.4.x] Revert "Fixed #13794 -- Fixed to_field usage in BaseInlineFormSet." This reverts commit b44519072e8a0ef56a0ae9e6e4a1fb04273eb0eb. stable/1.4.x branch is in security-fixes-only mode. --- django/forms/models.py | 8 ++--- .../model_formsets_regress/models.py | 9 ----- .../model_formsets_regress/tests.py | 36 +------------------ 3 files changed, 3 insertions(+), 50 deletions(-) diff --git a/django/forms/models.py b/django/forms/models.py index c16faae4abd4..cd8f027070e7 100644 --- a/django/forms/models.py +++ b/django/forms/models.py @@ -712,12 +712,8 @@ def _construct_form(self, i, **kwargs): # Remove the foreign key from the form's data form.data[form.add_prefix(self.fk.name)] = None - # Set the fk value here so that the form can do its validation. - fk_value = self.instance.pk - if self.fk.rel.field_name != self.fk.rel.to._meta.pk.name: - fk_value = getattr(self.instance, self.fk.rel.field_name) - fk_value = getattr(fk_value, 'pk', fk_value) - setattr(form.instance, self.fk.get_attname(), fk_value) + # Set the fk value here so that the form can do it's validation. + setattr(form.instance, self.fk.get_attname(), self.instance.pk) return form @classmethod diff --git a/tests/regressiontests/model_formsets_regress/models.py b/tests/regressiontests/model_formsets_regress/models.py index 2dcf76edac95..189ed8072e0e 100644 --- a/tests/regressiontests/model_formsets_regress/models.py +++ b/tests/regressiontests/model_formsets_regress/models.py @@ -9,15 +9,6 @@ class UserSite(models.Model): user = models.ForeignKey(User, to_field="username") data = models.IntegerField() -class UserProfile(models.Model): - user = models.ForeignKey(User, unique=True, to_field="username") - about = models.TextField() - -class ProfileNetwork(models.Model): - profile = models.ForeignKey(UserProfile, to_field="user") - network = models.IntegerField() - identifier = models.IntegerField() - class Place(models.Model): name = models.CharField(max_length=50) diff --git a/tests/regressiontests/model_formsets_regress/tests.py b/tests/regressiontests/model_formsets_regress/tests.py index 81a65cd38871..d058f1d8bcb9 100644 --- a/tests/regressiontests/model_formsets_regress/tests.py +++ b/tests/regressiontests/model_formsets_regress/tests.py @@ -6,10 +6,7 @@ from django.forms.models import modelform_factory, inlineformset_factory, modelformset_factory, BaseModelFormSet from django.test import TestCase -from .models import ( - User, UserSite, UserProfile, ProfileNetwork, Restaurant, Manager, Network, - Host, -) +from .models import User, UserSite, Restaurant, Manager, Network, Host class InlineFormsetTests(TestCase): @@ -156,37 +153,6 @@ def test_formset_over_inherited_model(self): else: self.fail('Errors found on formset:%s' % form_set.errors) - def test_inline_model_with_to_field(self): - """ - #13794 --- An inline model with a to_field of a formset with instance - has working relations. - """ - FormSet = inlineformset_factory(User, UserSite, exclude=('is_superuser',)) - - user = User.objects.create(username="guido", serial=1337) - UserSite.objects.create(user=user, data=10) - formset = FormSet(instance=user) - - # Testing the inline model's relation - self.assertEqual(formset[0].instance.user_id, "guido") - - def test_inline_model_with_to_field_to_rel(self): - """ - #13794 --- An inline model with a to_field to a related field of a - formset with instance has working relations. - """ - FormSet = inlineformset_factory(UserProfile, ProfileNetwork, exclude=[]) - - user = User.objects.create(username="guido", serial=1337, pk=1) - self.assertEqual(user.pk, 1) - profile = UserProfile.objects.create(user=user, about="about", pk=2) - self.assertEqual(profile.pk, 2) - ProfileNetwork.objects.create(profile=profile, network=10, identifier=10) - formset = FormSet(instance=profile) - - # Testing the inline model's relation - self.assertEqual(formset[0].instance.profile_id, 1) - def test_formset_with_none_instance(self): "A formset with instance=None can be created. Regression for #11872" Form = modelform_factory(User) From 778a5553426965d96e3118e438a28ae3cbd209d0 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 25 Jul 2014 09:46:15 -0400 Subject: [PATCH 301/367] [1.4.x] Added tests/requirements/py2.txt. This follows the convention used in other branches so we don't need a special case in the build script for 1.4. --- tests/requirements/py2.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/requirements/py2.txt diff --git a/tests/requirements/py2.txt b/tests/requirements/py2.txt new file mode 100644 index 000000000000..a3e81b8dcf5c --- /dev/null +++ b/tests/requirements/py2.txt @@ -0,0 +1 @@ +-r base.txt From bc03817b42900c24643aaa086cbbf41d96c08dde Mon Sep 17 00:00:00 2001 From: Erik Romijn Date: Sat, 2 Aug 2014 19:01:23 +0200 Subject: [PATCH 302/367] [1.4.x] Fixed #23149 -- Clarified note on HTTPOnly in cookie-based session docs Backport of e26366da44bb343e7a95d01ff0dd18b8026c2802 from master. --- docs/topics/http/sessions.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/http/sessions.txt b/docs/topics/http/sessions.txt index 47bb7bb3c32d..bb9d73af97d0 100644 --- a/docs/topics/http/sessions.txt +++ b/docs/topics/http/sessions.txt @@ -111,7 +111,7 @@ and the :setting:`SECRET_KEY` setting. .. note:: It's recommended to leave the :setting:`SESSION_COOKIE_HTTPONLY` setting - ``True`` to prevent tampering of the stored data from JavaScript. + on ``True`` to prevent access to the stored data from JavaScript. .. warning:: From d23d19c15eb816cc0c7ff1831fee050779db9873 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 6 Aug 2014 08:28:51 -0400 Subject: [PATCH 303/367] [1.4.x] Fixed #23239 -- Clarified a phrase in the contrib.markup docs. Backport of e0fb48c254 from stable/1.5.x --- docs/ref/contrib/markup.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/contrib/markup.txt b/docs/ref/contrib/markup.txt index 0b4a2072ace7..6bbc64688198 100644 --- a/docs/ref/contrib/markup.txt +++ b/docs/ref/contrib/markup.txt @@ -61,8 +61,8 @@ Markdown -------- The Python Markdown library supports options named "safe_mode" and -"enable_attributes". Both relate to the security of the output. To enable both -options in tandem, the markdown filter supports the "safe" argument. +"enable_attributes". Both relate to the security of the output. To use the safe +settings of both options, the markdown filter supports the "safe" argument:: {{ markdown_content_var|markdown:"safe" }} From 399052d224143c3dbd7e7bbbff14ff577881c669 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 8 Aug 2014 12:45:18 -0400 Subject: [PATCH 304/367] [1.4.x] Noted that django-jython requires Django 1.7. Backport of 72e98d5c16 from stable/1.6.x --- docs/howto/jython.txt | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/docs/howto/jython.txt b/docs/howto/jython.txt index 28cd2c9f4a83..2bbbf5297a60 100644 --- a/docs/howto/jython.txt +++ b/docs/howto/jython.txt @@ -4,17 +4,7 @@ Running Django on Jython .. index:: Jython, Java, JVM -As of January 2014, the latest release of `django-jython`_ supports Django 1.3 -which is no longer supported (receiving fixes or security updates) by the -Django Project. We therefore recommend that you do not try to run Django on -Jython at this time. - -The django-jython project is `seeking contributors`_ to help update its code for -newer versions of Django. You can select an older version of this documentation -to see the instructions we had for using Django with Jython. If django-jython -is updated and please `file a ticket`_ and we'll be happy to update our -documentation accordingly. +`django-jython`_ supports Django 1.7. Please use that version of the +documentation for details. .. _`django-jython`: http://code.google.com/p/django-jython/ -.. _`seeking contributors`: https://groups.google.com/d/topic/django-jython-dev/oZpKucQpz7I/discussion -.. _`file a ticket`: https://code.djangoproject.com/newticket From 88cb7aa6aa2272b02aa3914111083c4cd3094f1b Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 7 Aug 2014 09:20:59 -0400 Subject: [PATCH 305/367] [1.4.x] Added a warning that remove_tags() output shouldn't be considered safe. Backport of 7efce77de2 from master --- docs/ref/templates/builtins.txt | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index e2734c974791..4b1a6be7cf29 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -1875,15 +1875,27 @@ Removes a space-separated list of [X]HTML tags from the output. For example:: - {{ value|removetags:"b span"|safe }} + {{ value|removetags:"b span" }} If ``value`` is ``"Joel a slug"`` the -output will be ``"Joel a slug"``. +unescaped output will be ``"Joel a slug"``. Note that this filter is case-sensitive. If ``value`` is ``"Joel a slug"`` the -output will be ``"Joel a slug"``. +unescaped output will be ``"Joel a slug"``. + +.. admonition:: No safety guarantee + + Note that ``removetags`` doesn't give any guarantee about its output being + HTML safe. In particular, it doesn't work recursively, so an input like + ``"ript>alert('XSS')ript>"`` won't be safe even if + you apply ``|removetags:"script"``. So if the input is user provided, + **NEVER** apply the ``safe`` filter to a ``removetags`` output. If you are + looking for something more robust, you can use the ``bleach`` Python + library, notably its `clean`_ method. + +.. _clean: http://bleach.readthedocs.org/en/latest/clean.html .. templatefilter:: rjust @@ -2000,10 +2012,10 @@ output will be ``"Joel is a slug"``. .. admonition:: No safety guarantee Note that ``striptags`` doesn't give any guarantee about its output being - entirely HTML safe, particularly with non valid HTML input. So **NEVER** - apply the ``safe`` filter to a ``striptags`` output. - If you are looking for something more robust, you can use the ``bleach`` - Python library, notably its `clean`_ method. + HTML safe, particularly with non valid HTML input. So **NEVER** apply the + ``safe`` filter to a ``striptags`` output. If you are looking for something + more robust, you can use the ``bleach`` Python library, notably its + `clean`_ method. .. _clean: http://bleach.readthedocs.org/en/latest/clean.html From 4d5e972a2c9f3e2f6ce115f7fbe44df8dd8612ef Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 5 Aug 2014 15:20:05 -0400 Subject: [PATCH 306/367] [1.4.x] Added release note stub for 1.4.14. --- docs/releases/1.4.14.txt | 7 +++++++ docs/releases/index.txt | 1 + 2 files changed, 8 insertions(+) create mode 100644 docs/releases/1.4.14.txt diff --git a/docs/releases/1.4.14.txt b/docs/releases/1.4.14.txt new file mode 100644 index 000000000000..d0032e53990d --- /dev/null +++ b/docs/releases/1.4.14.txt @@ -0,0 +1,7 @@ +=========================== +Django 1.4.14 release notes +=========================== + +*Under development* + +Django 1.4.14 fixes several security issues in 1.4.13. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index d699069610d5..246c688b0bf8 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.14 1.4.13 1.4.12 1.4.11 From c2fe73133b62a1d9e8f7a6b43966570b14618d7e Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Thu, 17 Jul 2014 21:59:28 +0200 Subject: [PATCH 307/367] [1.4.x] Prevented reverse() from generating URLs pointing to other hosts. This is a security fix. Disclosure following shortly. --- django/core/urlresolvers.py | 2 ++ docs/releases/1.4.14.txt | 13 +++++++++++++ tests/regressiontests/urlpatterns_reverse/tests.py | 3 +++ tests/regressiontests/urlpatterns_reverse/urls.py | 3 +++ 4 files changed, 21 insertions(+) diff --git a/django/core/urlresolvers.py b/django/core/urlresolvers.py index 6d75cb7cae6b..006bb1949d76 100644 --- a/django/core/urlresolvers.py +++ b/django/core/urlresolvers.py @@ -406,6 +406,8 @@ def _reverse_with_prefix(self, lookup_view, _prefix, *args, **kwargs): unicode_kwargs = dict([(k, force_unicode(v)) for (k, v) in kwargs.items()]) candidate = (prefix_norm + result) % unicode_kwargs if re.search(u'^%s%s' % (_prefix, pattern), candidate, re.UNICODE): + if candidate.startswith('//'): + candidate = '/%%2F%s' % candidate[2:] return candidate # lookup_view can be URL label, or dotted path, or callable, Any of # these can be passed in at the top, but callables are not friendly in diff --git a/docs/releases/1.4.14.txt b/docs/releases/1.4.14.txt index d0032e53990d..28390c96a40a 100644 --- a/docs/releases/1.4.14.txt +++ b/docs/releases/1.4.14.txt @@ -5,3 +5,16 @@ Django 1.4.14 release notes *Under development* Django 1.4.14 fixes several security issues in 1.4.13. + +:func:`~django.core.urlresolvers.reverse()` could generate URLs pointing to other hosts +======================================================================================= + +In certain situations, URL reversing could generate scheme-relative URLs (URLs +starting with two slashes), which could unexpectedly redirect a user to a +different host. An attacker could exploit this, for example, by redirecting +users to a phishing site designed to ask for user's passwords. + +To remedy this, URL reversing now ensures that no URL starts with two slashes +(//), replacing the second slash with its URL encoded counterpart (%2F). This +approach ensures that semantics stay the same, while making the URL relative to +the domain and not to the scheme. diff --git a/tests/regressiontests/urlpatterns_reverse/tests.py b/tests/regressiontests/urlpatterns_reverse/tests.py index 0ea732b8abda..6b4a06f3bbf2 100644 --- a/tests/regressiontests/urlpatterns_reverse/tests.py +++ b/tests/regressiontests/urlpatterns_reverse/tests.py @@ -142,6 +142,9 @@ ('defaults', '/defaults_view2/3/', [], {'arg1': 3, 'arg2': 2}), ('defaults', NoReverseMatch, [], {'arg1': 3, 'arg2': 3}), ('defaults', NoReverseMatch, [], {'arg2': 1}), + + # Security tests + ('security', '/%2Fexample.com/security/', ['/example.com'], {}), ) class NoURLPatternsTests(TestCase): diff --git a/tests/regressiontests/urlpatterns_reverse/urls.py b/tests/regressiontests/urlpatterns_reverse/urls.py index 7aae7c469140..0d3f8c3ed5a8 100644 --- a/tests/regressiontests/urlpatterns_reverse/urls.py +++ b/tests/regressiontests/urlpatterns_reverse/urls.py @@ -71,4 +71,7 @@ (r'defaults_view2/(?P\d+)/', 'defaults_view', {'arg2': 2}, 'defaults'), url('^includes/', include(other_patterns)), + + # Security tests + url('(.+)/security/$', empty_view, name='security'), ) From 30042d475bf084c6723c6217a21598d9247a9c41 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 8 Aug 2014 10:20:08 -0400 Subject: [PATCH 308/367] [1.4.x] Fixed #23157 -- Removed O(n) algorithm when uploading duplicate file names. This is a security fix. Disclosure following shortly. --- django/core/files/storage.py | 11 +++++----- docs/howto/custom-file-storage.txt | 12 +++++++++-- docs/ref/files/storage.txt | 16 +++++++++++--- docs/releases/1.4.14.txt | 20 ++++++++++++++++++ tests/modeltests/files/tests.py | 21 ++++++++++++------- tests/regressiontests/file_storage/tests.py | 23 +++++++++++++-------- 6 files changed, 75 insertions(+), 28 deletions(-) diff --git a/django/core/files/storage.py b/django/core/files/storage.py index aa62175819fe..304076414201 100644 --- a/django/core/files/storage.py +++ b/django/core/files/storage.py @@ -1,13 +1,13 @@ import os import errno import urlparse -import itertools from datetime import datetime from django.conf import settings from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.core.files import locks, File from django.core.files.move import file_move_safe +from django.utils.crypto import get_random_string from django.utils.encoding import force_unicode, filepath_to_uri from django.utils.functional import LazyObject from django.utils.importlib import import_module @@ -63,13 +63,12 @@ def get_available_name(self, name): """ dir_name, file_name = os.path.split(name) file_root, file_ext = os.path.splitext(file_name) - # If the filename already exists, add an underscore and a number (before - # the file extension, if one exists) to the filename until the generated - # filename doesn't exist. - count = itertools.count(1) + # If the filename already exists, add an underscore and a random 7 + # character alphanumeric string (before the file extension, if one + # exists) to the filename until the generated filename doesn't exist. while self.exists(name): # file_ext includes the dot. - name = os.path.join(dir_name, "%s_%s%s" % (file_root, count.next(), file_ext)) + name = os.path.join(dir_name, "%s_%s%s" % (file_root, get_random_string(7), file_ext)) return name diff --git a/docs/howto/custom-file-storage.txt b/docs/howto/custom-file-storage.txt index 5f1dae17ef84..ffcbe4a085c6 100644 --- a/docs/howto/custom-file-storage.txt +++ b/docs/howto/custom-file-storage.txt @@ -86,5 +86,13 @@ the provided filename into account. The ``name`` argument passed to this method will have already cleaned to a filename valid for the storage system, according to the ``get_valid_name()`` method described above. -The code provided on ``Storage`` simply appends ``"_1"``, ``"_2"``, etc. to the -filename until it finds one that's available in the destination directory. +.. versionchanged:: 1.4.14 + + If a file with ``name`` already exists, an underscore plus a random 7 + character alphanumeric string is appended to the filename before the + extension. + + Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``, + etc.) was appended to the filename until an avaible name in the destination + directory was found. A malicious user could exploit this deterministic + algorithm to create a denial-of-service attack. diff --git a/docs/ref/files/storage.txt b/docs/ref/files/storage.txt index b3f890984753..252e8e4d52a9 100644 --- a/docs/ref/files/storage.txt +++ b/docs/ref/files/storage.txt @@ -18,7 +18,7 @@ Django provides two convenient ways to access the current storage class: .. function:: get_storage_class([import_path=None]) Returns a class or module which implements the storage API. - + When called without the ``import_path`` parameter ``get_storage_class`` will return the current default storage system as defined by :setting:`DEFAULT_FILE_STORAGE`. If ``import_path`` is provided, @@ -35,9 +35,9 @@ The FileSystemStorage Class basic file storage on a local filesystem. It inherits from :class:`~django.core.files.storage.Storage` and provides implementations for all the public methods thereof. - + .. note:: - + The :class:`FileSystemStorage.delete` method will not raise raise an exception if the given file name does not exist. @@ -85,6 +85,16 @@ The Storage Class available for new content to be written to on the target storage system. + .. versionchanged:: 1.4.14 + + If a file with ``name`` already exists, an underscore plus a random 7 + character alphanumeric string is appended to the filename before the + extension. + + Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``, + etc.) was appended to the filename until an avaible name in the + destination directory was found. A malicious user could exploit this + deterministic algorithm to create a denial-of-service attack. .. method:: get_valid_name(name) diff --git a/docs/releases/1.4.14.txt b/docs/releases/1.4.14.txt index 28390c96a40a..6c140ee6dc13 100644 --- a/docs/releases/1.4.14.txt +++ b/docs/releases/1.4.14.txt @@ -18,3 +18,23 @@ To remedy this, URL reversing now ensures that no URL starts with two slashes (//), replacing the second slash with its URL encoded counterpart (%2F). This approach ensures that semantics stay the same, while making the URL relative to the domain and not to the scheme. + +File upload denial-of-service +============================= + +Before this release, Django's file upload handing in its default configuration +may degrade to producing a huge number of ``os.stat()`` system calls when a +duplicate filename is uploaded. Since ``stat()`` may invoke IO, this may produce +a huge data-dependent slowdown that slowly worsens over time. The net result is +that given enough time, a user with the ability to upload files can cause poor +performance in the upload handler, eventually causing it to become very slow +simply by uploading 0-byte files. At this point, even a slow network connection +and few HTTP requests would be all that is necessary to make a site unavailable. + +We've remedied the issue by changing the algorithm for generating file names +if a file with the uploaded name already exists. +:meth:`Storage.get_available_name() +` now appends an +underscore plus a random 7 character alphanumeric string (e.g. ``"_x3a1gho"``), +rather than iterating through an underscore followed by a number (e.g. ``"_1"``, +``"_2"``, etc.). diff --git a/tests/modeltests/files/tests.py b/tests/modeltests/files/tests.py index 50017be359a3..3864d27f6619 100644 --- a/tests/modeltests/files/tests.py +++ b/tests/modeltests/files/tests.py @@ -8,10 +8,14 @@ from django.core.files.base import ContentFile from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase +from django.utils import six from .models import Storage, temp_storage, temp_storage_location +FILE_SUFFIX_REGEX = '[A-Za-z0-9]{7}' + + class FileTests(TestCase): def tearDown(self): shutil.rmtree(temp_storage_location) @@ -57,27 +61,28 @@ def test_files(self): # Save another file with the same name. obj2 = Storage() obj2.normal.save("django_test.txt", ContentFile("more content")) - self.assertEqual(obj2.normal.name, "tests/django_test_1.txt") + obj2_name = obj2.normal.name + six.assertRegex(self, obj2_name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX) self.assertEqual(obj2.normal.size, 12) # Push the objects into the cache to make sure they pickle properly cache.set("obj1", obj1) cache.set("obj2", obj2) - self.assertEqual(cache.get("obj2").normal.name, "tests/django_test_1.txt") + six.assertRegex(self, cache.get("obj2").normal.name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX) # Deleting an object does not delete the file it uses. obj2.delete() obj2.normal.save("django_test.txt", ContentFile("more content")) - self.assertEqual(obj2.normal.name, "tests/django_test_2.txt") + self.assertNotEqual(obj2_name, obj2.normal.name) + six.assertRegex(self, obj2.normal.name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX) # Multiple files with the same name get _N appended to them. - objs = [Storage() for i in range(3)] + objs = [Storage() for i in range(2)] for o in objs: o.normal.save("multiple_files.txt", ContentFile("Same Content")) - self.assertEqual( - [o.normal.name for o in objs], - ["tests/multiple_files.txt", "tests/multiple_files_1.txt", "tests/multiple_files_2.txt"] - ) + names = [o.normal.name for o in objs] + self.assertEqual(names[0], "tests/multiple_files.txt") + six.assertRegex(self, names[1], "tests/multiple_files_%s.txt" % FILE_SUFFIX_REGEX) for o in objs: o.delete() diff --git a/tests/regressiontests/file_storage/tests.py b/tests/regressiontests/file_storage/tests.py index 769e2c7371ba..7c8027a2ebdd 100644 --- a/tests/regressiontests/file_storage/tests.py +++ b/tests/regressiontests/file_storage/tests.py @@ -23,7 +23,7 @@ from django.core.files.storage import FileSystemStorage, get_storage_class from django.core.files.uploadedfile import UploadedFile from django.test import SimpleTestCase -from django.utils import unittest +from django.utils import six, unittest # Try to import PIL in either of the two ways it can end up installed. # Checking for the existence of Image is enough for CPython, but @@ -37,6 +37,9 @@ Image = None +FILE_SUFFIX_REGEX = '[A-Za-z0-9]{7}' + + class GetStorageClassTests(SimpleTestCase): def test_get_filesystem_storage(self): @@ -417,10 +420,9 @@ def test_race_condition(self): self.thread.start() name = self.save_file('conflict') self.thread.join() - self.assertTrue(self.storage.exists('conflict')) - self.assertTrue(self.storage.exists('conflict_1')) - self.storage.delete('conflict') - self.storage.delete('conflict_1') + files = sorted(os.listdir(self.storage_dir)) + self.assertEqual(files[0], 'conflict') + six.assertRegex(self, files[1], 'conflict_%s' % FILE_SUFFIX_REGEX) class FileStoragePermissions(unittest.TestCase): def setUp(self): @@ -457,9 +459,10 @@ def test_directory_with_dot(self): self.storage.save('dotted.path/test', ContentFile("1")) self.storage.save('dotted.path/test', ContentFile("2")) + files = sorted(os.listdir(os.path.join(self.storage_dir, 'dotted.path'))) self.assertFalse(os.path.exists(os.path.join(self.storage_dir, 'dotted_.path'))) - self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/test'))) - self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/test_1'))) + self.assertEqual(files[0], 'test') + six.assertRegex(self, files[1], 'test_%s' % FILE_SUFFIX_REGEX) def test_first_character_dot(self): """ @@ -472,10 +475,12 @@ def test_first_character_dot(self): self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/.test'))) # Before 2.6, a leading dot was treated as an extension, and so # underscore gets added to beginning instead of end. + files = sorted(os.listdir(os.path.join(self.storage_dir, 'dotted.path'))) + self.assertEqual(files[0], '.test') if sys.version_info < (2, 6): - self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/_1.test'))) + six.assertRegex(self, files[1], '_%s.test' % FILE_SUFFIX_REGEX) else: - self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/.test_1'))) + six.assertRegex(self, files[1], '.test_%s' % FILE_SUFFIX_REGEX) class DimensionClosingBug(unittest.TestCase): """ From c9e3b9949cd55f090591fbdc4a114fcb8368b6d9 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Mon, 11 Aug 2014 12:04:53 -0400 Subject: [PATCH 309/367] [1.4.x] Fixed #23066 -- Modified RemoteUserMiddleware to logout on REMOTE_USE change. This is a security fix. Disclosure following shortly. --- django/contrib/auth/middleware.py | 28 +++++++++++++++++++++--- django/contrib/auth/tests/remote_user.py | 18 +++++++++++++++ docs/releases/1.4.14.txt | 9 ++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/django/contrib/auth/middleware.py b/django/contrib/auth/middleware.py index df616a92435d..1ca2b506568c 100644 --- a/django/contrib/auth/middleware.py +++ b/django/contrib/auth/middleware.py @@ -1,4 +1,5 @@ from django.contrib import auth +from django.contrib.auth.backends import RemoteUserBackend from django.core.exceptions import ImproperlyConfigured from django.utils.functional import SimpleLazyObject @@ -47,9 +48,11 @@ def process_request(self, request): try: username = request.META[self.header] except KeyError: - # If specified header doesn't exist then return (leaving - # request.user set to AnonymousUser by the - # AuthenticationMiddleware). + # If specified header doesn't exist then remove any existing + # authenticated remote-user, or return (leaving request.user set to + # AnonymousUser by the AuthenticationMiddleware). + if request.user.is_authenticated(): + self._remove_invalid_user(request) return # If the user is already authenticated and that user is the user we are # getting passed in the headers, then the correct user is already @@ -57,6 +60,11 @@ def process_request(self, request): if request.user.is_authenticated(): if request.user.username == self.clean_username(username, request): return + else: + # An authenticated user is associated with the request, but + # it does not match the authorized user in the header. + self._remove_invalid_user(request) + # We are seeing this user for the first time in this session, attempt # to authenticate the user. user = auth.authenticate(remote_user=username) @@ -78,3 +86,17 @@ def clean_username(self, username, request): except AttributeError: # Backend has no clean_username method. pass return username + + def _remove_invalid_user(self, request): + """ + Removes the current authenticated user in the request which is invalid + but only if the user is authenticated via the RemoteUserBackend. + """ + try: + stored_backend = auth.load_backend(request.session.get(auth.BACKEND_SESSION_KEY, '')) + except ImproperlyConfigured: + # backend failed to load + auth.logout(request) + else: + if isinstance(stored_backend, RemoteUserBackend): + auth.logout(request) diff --git a/django/contrib/auth/tests/remote_user.py b/django/contrib/auth/tests/remote_user.py index fa324781d274..d656b0ba8fbb 100644 --- a/django/contrib/auth/tests/remote_user.py +++ b/django/contrib/auth/tests/remote_user.py @@ -95,6 +95,24 @@ def test_last_login(self): response = self.client.get('/remote_user/', REMOTE_USER=self.known_user) self.assertEqual(default_login, response.context['user'].last_login) + def test_user_switch_forces_new_login(self): + """ + Tests that if the username in the header changes between requests + that the original user is logged out + """ + User.objects.create(username='knownuser') + # Known user authenticates + response = self.client.get('/remote_user/', + **{'REMOTE_USER': self.known_user}) + self.assertEqual(response.context['user'].username, 'knownuser') + # During the session, the REMOTE_USER changes to a different user. + response = self.client.get('/remote_user/', + **{'REMOTE_USER': "newnewuser"}) + # Ensure that the current user is not the prior remote_user + # In backends that create a new user, username is "newnewuser" + # In backends that do not create new users, it is '' (anonymous user) + self.assertNotEqual(response.context['user'].username, 'knownuser') + def tearDown(self): """Restores settings to avoid breaking other tests.""" settings.MIDDLEWARE_CLASSES = self.curr_middleware diff --git a/docs/releases/1.4.14.txt b/docs/releases/1.4.14.txt index 6c140ee6dc13..811c3f67eaa9 100644 --- a/docs/releases/1.4.14.txt +++ b/docs/releases/1.4.14.txt @@ -38,3 +38,12 @@ if a file with the uploaded name already exists. underscore plus a random 7 character alphanumeric string (e.g. ``"_x3a1gho"``), rather than iterating through an underscore followed by a number (e.g. ``"_1"``, ``"_2"``, etc.). + +``RemoteUserMiddleware`` session hijacking +========================================== + +When using the :class:`~django.contrib.auth.middleware.RemoteUserMiddleware` +and the ``RemoteUserBackend``, a change to the ``REMOTE_USER`` header between +requests without an intervening logout could result in the prior user's session +being co-opted by the subsequent user. The middleware now logs the user out on +a failed login attempt. From 027bd348642007617518379f8b02546abacaa6e0 Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Mon, 11 Aug 2014 15:36:16 -0400 Subject: [PATCH 310/367] [1.4.x] Prevented data leakage in contrib.admin via query string manipulation. This is a security fix. Disclosure following shortly. --- django/contrib/admin/exceptions.py | 6 ++++++ django/contrib/admin/options.py | 18 ++++++++++++++++++ django/contrib/admin/views/main.py | 6 +++++- docs/releases/1.4.14.txt | 15 +++++++++++++++ tests/regressiontests/admin_views/tests.py | 21 +++++++++++++++++---- 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 django/contrib/admin/exceptions.py diff --git a/django/contrib/admin/exceptions.py b/django/contrib/admin/exceptions.py new file mode 100644 index 000000000000..d9de47eefda5 --- /dev/null +++ b/django/contrib/admin/exceptions.py @@ -0,0 +1,6 @@ +from django.core.exceptions import SuspiciousOperation + + +class DisallowedModelAdminToField(SuspiciousOperation): + """Invalid to_field was passed to admin view via URL query string""" + pass diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 78a08cd12048..cf3497b93c7d 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -269,6 +269,24 @@ def lookup_allowed(self, lookup, value): clean_lookup = LOOKUP_SEP.join(parts) return clean_lookup in self.list_filter or clean_lookup == self.date_hierarchy + def to_field_allowed(self, request, to_field): + opts = self.model._meta + + try: + field = opts.get_field(to_field) + except FieldDoesNotExist: + return False + + # Make sure at least one of the models registered for this site + # references this field. + registered_models = self.admin_site._registry + for related_object in opts.get_all_related_objects(): + if (related_object.model in registered_models and + field == related_object.field.rel.get_related_field()): + return True + + return False + def has_add_permission(self, request): """ Returns True if the given request has permission to add an object. diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py index 9d5c30434d73..7f4bb2f7e5db 100644 --- a/django/contrib/admin/views/main.py +++ b/django/contrib/admin/views/main.py @@ -10,6 +10,7 @@ from django.utils.http import urlencode from django.contrib.admin import FieldListFilter +from django.contrib.admin.exceptions import DisallowedModelAdminToField from django.contrib.admin.options import IncorrectLookupParameters from django.contrib.admin.util import (quote, get_fields_from_path, lookup_needs_distinct, prepare_lookup_value) @@ -56,7 +57,10 @@ def __init__(self, request, model, list_display, list_display_links, self.page_num = 0 self.show_all = ALL_VAR in request.GET self.is_popup = IS_POPUP_VAR in request.GET - self.to_field = request.GET.get(TO_FIELD_VAR) + to_field = request.GET.get(TO_FIELD_VAR) + if to_field and not model_admin.to_field_allowed(request, to_field): + raise DisallowedModelAdminToField("The field %s cannot be referenced." % to_field) + self.to_field = to_field self.params = dict(request.GET.items()) if PAGE_VAR in self.params: del self.params[PAGE_VAR] diff --git a/docs/releases/1.4.14.txt b/docs/releases/1.4.14.txt index 811c3f67eaa9..98de8b018e2f 100644 --- a/docs/releases/1.4.14.txt +++ b/docs/releases/1.4.14.txt @@ -47,3 +47,18 @@ and the ``RemoteUserBackend``, a change to the ``REMOTE_USER`` header between requests without an intervening logout could result in the prior user's session being co-opted by the subsequent user. The middleware now logs the user out on a failed login attempt. + +Data leakage via query string manipulation in ``contrib.admin`` +=============================================================== + +In older versions of Django it was possible to reveal any field's data by +modifying the "popup" and "to_field" parameters of the query string on an admin +change form page. For example, requesting a URL like +``/admin/auth/user/?pop=1&t=password`` and viewing the page's HTML allowed +viewing the password hash of each user. While the admin requires users to have +permissions to view the change form pages in the first place, this could leak +data if you rely on users having access to view only certain fields on a model. + +To address the issue, an exception will now be raised if a ``to_field`` value +that isn't a related field to a model that has been registered with the admin +is specified. diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index b695453e17f7..c40f00917758 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -13,11 +13,12 @@ from django.core.urlresolvers import reverse # Register auth models with the admin. from django.contrib import admin +from django.contrib.admin.exceptions import DisallowedModelAdminToField from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.contrib.admin.models import LogEntry, DELETION from django.contrib.admin.sites import LOGIN_FORM_KEY from django.contrib.admin.util import quote -from django.contrib.admin.views.main import IS_POPUP_VAR +from django.contrib.admin.views.main import IS_POPUP_VAR, TO_FIELD_VAR from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.models import Group, User, Permission, UNUSABLE_PASSWORD @@ -572,6 +573,19 @@ def test_disallowed_filtering(self): response = self.client.get("/test_admin/admin/admin_views/workhour/?employee__person_ptr__exact=%d" % e1.pk) self.assertEqual(response.status_code, 200) + def test_disallowed_to_field(self): + with self.assertRaises(DisallowedModelAdminToField): + response = self.client.get("/test_admin/admin/admin_views/section/", {TO_FIELD_VAR: 'missing_field'}) + + # Specifying a field that is not refered by any other model registered + # to this admin site should raise an exception. + with self.assertRaises(DisallowedModelAdminToField): + response = self.client.get("/test_admin/admin/admin_views/section/", {TO_FIELD_VAR: 'name'}) + + # Specifying a field referenced by another model should be allowed. + response = self.client.get("/test_admin/admin/admin_views/section/", {TO_FIELD_VAR: 'id'}) + self.assertEqual(response.status_code, 200) + def test_allowed_filtering_15103(self): """ Regressions test for ticket 15103 - filtering on fields defined in a @@ -2061,10 +2075,9 @@ def test_with_fk_to_field(self): """Ensure that the to_field GET parameter is preserved when a search is performed. Refs #10918. """ - from django.contrib.admin.views.main import TO_FIELD_VAR - response = self.client.get('/test_admin/admin/auth/user/?q=joe&%s=username' % TO_FIELD_VAR) + response = self.client.get('/test_admin/admin/auth/user/?q=joe&%s=id' % TO_FIELD_VAR) self.assertContains(response, "\n1 user\n") - self.assertContains(response, '', html=True) + self.assertContains(response, '' % TO_FIELD_VAR, html=True) def test_exact_matches(self): response = self.client.get('/test_admin/admin/admin_views/recommendation/?q=bar') From 4fce0193d21b893f0ba186b10cfcdd6350fa5865 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Wed, 20 Aug 2014 15:00:40 -0500 Subject: [PATCH 311/367] [1.4.x] Bump version numbers for security release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index bfd4caf5925f..4e845509bcdc 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 14, 'alpha', 0) +VERSION = (1, 4, 14, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index e933373b8f66..c09a6465c324 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.13' +version = '1.4.14' # The full version, including alpha/beta/rc tags. -release = '1.4.13' +release = '1.4.14' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index 4bc36b056329..1aa2f302ff7c 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.13.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.14.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From e484df76b68ea8b23e5d6d07d61d408c8d756931 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 20 Aug 2014 16:31:45 -0400 Subject: [PATCH 312/367] [1.4.x] Added dates to release notes. --- docs/releases/1.4.14.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.4.14.txt b/docs/releases/1.4.14.txt index 98de8b018e2f..d5d1a66f8974 100644 --- a/docs/releases/1.4.14.txt +++ b/docs/releases/1.4.14.txt @@ -2,7 +2,7 @@ Django 1.4.14 release notes =========================== -*Under development* +*August 20, 2014* Django 1.4.14 fixes several security issues in 1.4.13. From 27c682ffa08264dee63c82751ebfa9d8eeaf62df Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 20 Aug 2014 16:36:24 -0400 Subject: [PATCH 313/367] [1.4.x] Bumped version number post-release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 4e845509bcdc..0403e4a2f2d7 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 14, 'final', 0) +VERSION = (1, 4, 15, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 8adc56ca78fa362dc59de81b641e6d65b6abf289 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 26 Aug 2014 09:44:24 -0400 Subject: [PATCH 314/367] [1.4.x] Fixed spelling mistake in file docs. Backport of a3e88e64a4 from master --- docs/howto/custom-file-storage.txt | 2 +- docs/ref/files/storage.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/howto/custom-file-storage.txt b/docs/howto/custom-file-storage.txt index ffcbe4a085c6..e1c61fa1893f 100644 --- a/docs/howto/custom-file-storage.txt +++ b/docs/howto/custom-file-storage.txt @@ -93,6 +93,6 @@ to the ``get_valid_name()`` method described above. extension. Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``, - etc.) was appended to the filename until an avaible name in the destination + etc.) was appended to the filename until an available name in the destination directory was found. A malicious user could exploit this deterministic algorithm to create a denial-of-service attack. diff --git a/docs/ref/files/storage.txt b/docs/ref/files/storage.txt index 252e8e4d52a9..f85d9297d1d1 100644 --- a/docs/ref/files/storage.txt +++ b/docs/ref/files/storage.txt @@ -92,7 +92,7 @@ The Storage Class extension. Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``, - etc.) was appended to the filename until an avaible name in the + etc.) was appended to the filename until an available name in the destination directory was found. A malicious user could exploit this deterministic algorithm to create a denial-of-service attack. From 4685026840f0e2b895f980b6a33ad1b282aa7852 Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Thu, 21 Aug 2014 11:55:23 -0400 Subject: [PATCH 315/367] [1.4.x] Fixed #23329 -- Allowed inherited and m2m fields to be referenced in the admin. Thanks to Trac alias Markush2010 and ross for the detailed reports. Backport of 3cbb759 from master --- django/contrib/admin/options.py | 10 ++++++---- docs/releases/1.4.15.txt | 13 +++++++++++++ docs/releases/index.txt | 1 + tests/regressiontests/admin_views/admin.py | 5 ++++- tests/regressiontests/admin_views/models.py | 18 ++++++++++++++++++ tests/regressiontests/admin_views/tests.py | 9 +++++++++ 6 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 docs/releases/1.4.15.txt diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index cf3497b93c7d..fa7a6f0b9cd6 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -278,11 +278,13 @@ def to_field_allowed(self, request, to_field): return False # Make sure at least one of the models registered for this site - # references this field. + # references this field through a FK or a M2M relationship. registered_models = self.admin_site._registry - for related_object in opts.get_all_related_objects(): - if (related_object.model in registered_models and - field == related_object.field.rel.get_related_field()): + for related_object in (opts.get_all_related_objects() + + opts.get_all_related_many_to_many_objects()): + related_model = related_object.model + if (any(issubclass(model, related_model) for model in registered_models) and + related_object.field.rel.get_related_field() == field): return True return False diff --git a/docs/releases/1.4.15.txt b/docs/releases/1.4.15.txt new file mode 100644 index 000000000000..e9498e8a8469 --- /dev/null +++ b/docs/releases/1.4.15.txt @@ -0,0 +1,13 @@ +=========================== +Django 1.4.15 release notes +=========================== + +*Under development* + +Django 1.4.15 fixes a regression in the 1.4.14 security release. + +Bugfixes +======== + +* Allowed inherited and m2m fields to be referenced in the admin + (`#22486 `_) diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 246c688b0bf8..1b1a5fe9558c 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.15 1.4.14 1.4.13 1.4.12 diff --git a/tests/regressiontests/admin_views/admin.py b/tests/regressiontests/admin_views/admin.py index 3369c557b7d5..04410dd0bc0d 100644 --- a/tests/regressiontests/admin_views/admin.py +++ b/tests/regressiontests/admin_views/admin.py @@ -27,7 +27,7 @@ Album, Question, Answer, ComplexSortedPerson, PrePopulatedPostLargeSlug, AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated, - RelatedPrepopulated) + RelatedPrepopulated, ReferencedByParent, ChildOfReferer, M2MReference) def callable_year(dt_value): @@ -616,6 +616,9 @@ class UnorderedObjectAdmin(admin.ModelAdmin): site.register(Report, ReportAdmin) site.register(MainPrepopulated, MainPrepopulatedAdmin) site.register(UnorderedObject, UnorderedObjectAdmin) +site.register(ReferencedByParent) +site.register(ChildOfReferer) +site.register(M2MReference) # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2. # That way we cover all four cases: diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py index e158e07603ee..6380abd0a93d 100644 --- a/tests/regressiontests/admin_views/models.py +++ b/tests/regressiontests/admin_views/models.py @@ -607,3 +607,21 @@ class UnorderedObject(models.Model): """ name = models.CharField(max_length=255) bool = models.BooleanField(default=True) + + +# Models for #23329 +class ReferencedByParent(models.Model): + pass + + +class ParentWithFK(models.Model): + fk = models.ForeignKey(ReferencedByParent) + + +class ChildOfReferer(ParentWithFK): + pass + + +class M2MReference(models.Model): + ref = models.ManyToManyField('self') + diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index c40f00917758..9c3cf234bc51 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -586,6 +586,15 @@ def test_disallowed_to_field(self): response = self.client.get("/test_admin/admin/admin_views/section/", {TO_FIELD_VAR: 'id'}) self.assertEqual(response.status_code, 200) + # Specifying a field referenced by another model though a m2m should be allowed. + response = self.client.get("/test_admin/admin/admin_views/m2mreference/", {TO_FIELD_VAR: 'id'}) + self.assertEqual(response.status_code, 200) + + # Specifying a field that is not refered by any other model directly registered + # to this admin site but registered through inheritance should be allowed. + response = self.client.get("/test_admin/admin/admin_views/referencedbyparent/", {TO_FIELD_VAR: 'id'}) + self.assertEqual(response.status_code, 200) + def test_allowed_filtering_15103(self): """ Regressions test for ticket 15103 - filtering on fields defined in a From 0517f498cd74fc52acec59fa97d442b344e4f4a9 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Tue, 2 Sep 2014 15:43:24 -0500 Subject: [PATCH 316/367] [1.4.x] Bump version numbers for bugfix release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 0403e4a2f2d7..513d099b8806 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 15, 'alpha', 0) +VERSION = (1, 4, 15, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index c09a6465c324..effb3eb586de 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.14' +version = '1.4.15' # The full version, including alpha/beta/rc tags. -release = '1.4.14' +release = '1.4.15' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index 1aa2f302ff7c..c8d78dc2506c 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.14.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.15.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 89157fe11fce1bcb69a871b04f8f371dc83cc9a1 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 2 Sep 2014 21:07:29 -0400 Subject: [PATCH 317/367] [1.4.x] Post release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 513d099b8806..366ffcae81e4 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 15, 'final', 0) +VERSION = (1, 4, 16, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 78085844a7b728fab156ce85bf3463e79cf85b98 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 2 Sep 2014 21:34:29 -0400 Subject: [PATCH 318/367] [1.4.x] Added dates to release notes. Backport of 0fd23545db from master --- docs/releases/1.4.15.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.4.15.txt b/docs/releases/1.4.15.txt index e9498e8a8469..115b479927a9 100644 --- a/docs/releases/1.4.15.txt +++ b/docs/releases/1.4.15.txt @@ -2,7 +2,7 @@ Django 1.4.15 release notes =========================== -*Under development* +*September 2, 2014* Django 1.4.15 fixes a regression in the 1.4.14 security release. From 065caafa70b6c422f73e364a4c241b6538969d7b Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Thu, 4 Sep 2014 17:04:53 -0400 Subject: [PATCH 319/367] [1.4.x] Fixed #23431 -- Allowed inline and hidden references to admin fields. This fixes a regression introduced by the 53ff096982 security fix. Thanks to @a1tus for the report and Tim for the review. refs #23329. Backport of 342ccbd from master --- django/contrib/admin/options.py | 13 +++++++++++-- docs/releases/1.4.16.txt | 13 +++++++++++++ docs/releases/index.txt | 1 + tests/regressiontests/admin_views/admin.py | 13 ++++++++++++- tests/regressiontests/admin_views/models.py | 12 ++++++++++++ tests/regressiontests/admin_views/tests.py | 7 ++++++- 6 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 docs/releases/1.4.16.txt diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index fa7a6f0b9cd6..0bf9ede6cbff 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -270,6 +270,10 @@ def lookup_allowed(self, lookup, value): return clean_lookup in self.list_filter or clean_lookup == self.date_hierarchy def to_field_allowed(self, request, to_field): + """ + Returns True if the model associated with this admin should be + allowed to be referenced by the specified field. + """ opts = self.model._meta try: @@ -279,8 +283,13 @@ def to_field_allowed(self, request, to_field): # Make sure at least one of the models registered for this site # references this field through a FK or a M2M relationship. - registered_models = self.admin_site._registry - for related_object in (opts.get_all_related_objects() + + registered_models = set() + for model, admin in self.admin_site._registry.items(): + registered_models.add(model) + for inline in admin.inlines: + registered_models.add(inline.model) + + for related_object in (opts.get_all_related_objects(include_hidden=True) + opts.get_all_related_many_to_many_objects()): related_model = related_object.model if (any(issubclass(model, related_model) for model in registered_models) and diff --git a/docs/releases/1.4.16.txt b/docs/releases/1.4.16.txt new file mode 100644 index 000000000000..7c6e2675a08f --- /dev/null +++ b/docs/releases/1.4.16.txt @@ -0,0 +1,13 @@ +=========================== +Django 1.4.16 release notes +=========================== + +*Under development* + +Django 1.4.16 fixes a regression in the 1.4.14 security release. + +Bugfixes +======== + +* Allowed inline and hidden references to admin fields + (`#23431 `_). diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 1b1a5fe9558c..bf7c0eceb45c 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.16 1.4.15 1.4.14 1.4.13 diff --git a/tests/regressiontests/admin_views/admin.py b/tests/regressiontests/admin_views/admin.py index 04410dd0bc0d..f1147f6c47f6 100644 --- a/tests/regressiontests/admin_views/admin.py +++ b/tests/regressiontests/admin_views/admin.py @@ -27,7 +27,8 @@ Album, Question, Answer, ComplexSortedPerson, PrePopulatedPostLargeSlug, AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated, - RelatedPrepopulated, ReferencedByParent, ChildOfReferer, M2MReference) + RelatedPrepopulated, ReferencedByParent, ChildOfReferer, M2MReference, + ReferencedByInline, InlineReference, InlineReferer) def callable_year(dt_value): @@ -570,6 +571,14 @@ class UnorderedObjectAdmin(admin.ModelAdmin): +class InlineReferenceInline(admin.TabularInline): + model = InlineReference + + +class InlineRefererAdmin(admin.ModelAdmin): + inlines = [InlineReferenceInline] + + site = admin.AdminSite(name="admin") site.register(Article, ArticleAdmin) site.register(CustomArticle, CustomArticleAdmin) @@ -619,6 +628,8 @@ class UnorderedObjectAdmin(admin.ModelAdmin): site.register(ReferencedByParent) site.register(ChildOfReferer) site.register(M2MReference) +site.register(ReferencedByInline) +site.register(InlineReferer, InlineRefererAdmin) # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2. # That way we cover all four cases: diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py index 6380abd0a93d..7378dcbe96f2 100644 --- a/tests/regressiontests/admin_views/models.py +++ b/tests/regressiontests/admin_views/models.py @@ -625,3 +625,15 @@ class ChildOfReferer(ParentWithFK): class M2MReference(models.Model): ref = models.ManyToManyField('self') +# Models for #23431 +class ReferencedByInline(models.Model): + pass + + +class InlineReference(models.Model): + fk = models.ForeignKey(ReferencedByInline, related_name='hidden+') + + +class InlineReferer(models.Model): + refs = models.ManyToManyField(InlineReference) + diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index 9c3cf234bc51..eb2caf88781c 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -590,11 +590,16 @@ def test_disallowed_to_field(self): response = self.client.get("/test_admin/admin/admin_views/m2mreference/", {TO_FIELD_VAR: 'id'}) self.assertEqual(response.status_code, 200) - # Specifying a field that is not refered by any other model directly registered + # #23329 - Specifying a field that is not refered by any other model directly registered # to this admin site but registered through inheritance should be allowed. response = self.client.get("/test_admin/admin/admin_views/referencedbyparent/", {TO_FIELD_VAR: 'id'}) self.assertEqual(response.status_code, 200) + # #23431 - Specifying a field that is only refered to by a inline of a registered + # model should be allowed. + response = self.client.get("/test_admin/admin/admin_views/referencedbyinline/", {TO_FIELD_VAR: 'id'}) + self.assertEqual(response.status_code, 200) + def test_allowed_filtering_15103(self): """ Regressions test for ticket 15103 - filtering on fields defined in a From ba2be2761341a6b3d8d578f16c92fa278c0a42bc Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Wed, 13 Mar 2013 09:50:45 +0100 Subject: [PATCH 320/367] [1.4.x] Fixed #20036 -- Improved GEOS version string parsing Thanks chikiro.spam at gmail.com for the report. --- django/contrib/gis/geos/libgeos.py | 13 +++++++++---- django/contrib/gis/geos/tests/test_geos.py | 18 ++++++++++-------- docs/releases/1.4.16.txt | 6 +++++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/django/contrib/gis/geos/libgeos.py b/django/contrib/gis/geos/libgeos.py index a4f5adf4d0d7..433a7d84c03f 100644 --- a/django/contrib/gis/geos/libgeos.py +++ b/django/contrib/gis/geos/libgeos.py @@ -101,8 +101,11 @@ def get_pointer_arr(n): geos_version.restype = c_char_p # Regular expression should be able to parse version strings such as -# '3.0.0rc4-CAPI-1.3.3', '3.0.0-CAPI-1.4.1' or '3.4.0dev-CAPI-1.8.0' -version_regex = re.compile(r'^(?P(?P\d+)\.(?P\d+)\.(?P\d+))((rc(?P\d+))|dev)?-CAPI-(?P\d+\.\d+\.\d+)$') +# '3.0.0rc4-CAPI-1.3.3', '3.0.0-CAPI-1.4.1', '3.4.0dev-CAPI-1.8.0' or '3.4.0dev-CAPI-1.8.0 r0' +version_regex = re.compile( + r'^(?P(?P\d+)\.(?P\d+)\.(?P\d+))' + r'((rc(?P\d+))|dev)?-CAPI-(?P\d+\.\d+\.\d+)( r\d+)?$' +) def geos_version_info(): """ Returns a dictionary containing the various version metadata parsed from @@ -112,8 +115,10 @@ def geos_version_info(): """ ver = geos_version() m = version_regex.match(ver) - if not m: raise GEOSException('Could not parse version info string "%s"' % ver) - return dict((key, m.group(key)) for key in ('version', 'release_candidate', 'capi_version', 'major', 'minor', 'subminor')) + if not m: + raise GEOSException('Could not parse version info string "%s"' % ver) + return dict((key, m.group(key)) for key in ( + 'version', 'release_candidate', 'capi_version', 'major', 'minor', 'subminor')) # Version numbers and whether or not prepared geometry support is available. _verinfo = geos_version_info() diff --git a/django/contrib/gis/geos/tests/test_geos.py b/django/contrib/gis/geos/tests/test_geos.py index ddd5d582265c..2cb6aff9f48f 100644 --- a/django/contrib/gis/geos/tests/test_geos.py +++ b/django/contrib/gis/geos/tests/test_geos.py @@ -1050,15 +1050,17 @@ def test27_valid_reason(self): print "\nEND - expecting GEOS_NOTICE; safe to ignore.\n" def test28_geos_version(self): - "Testing the GEOS version regular expression." + """Testing the GEOS version regular expression.""" from django.contrib.gis.geos.libgeos import version_regex - versions = [ ('3.0.0rc4-CAPI-1.3.3', '3.0.0'), - ('3.0.0-CAPI-1.4.1', '3.0.0'), - ('3.4.0dev-CAPI-1.8.0', '3.4.0') ] - for v, expected in versions: - m = version_regex.match(v) - self.assertTrue(m) - self.assertEqual(m.group('version'), expected) + versions = [('3.0.0rc4-CAPI-1.3.3', '3.0.0', '1.3.3'), + ('3.0.0-CAPI-1.4.1', '3.0.0', '1.4.1'), + ('3.4.0dev-CAPI-1.8.0', '3.4.0', '1.8.0'), + ('3.4.0dev-CAPI-1.8.0 r0', '3.4.0', '1.8.0')] + for v_init, v_geos, v_capi in versions: + m = version_regex.match(v_init) + self.assertTrue(m, msg="Unable to parse the version string '%s'" % v_init) + self.assertEqual(m.group('version'), v_geos) + self.assertEqual(m.group('capi_version'), v_capi) def suite(): diff --git a/docs/releases/1.4.16.txt b/docs/releases/1.4.16.txt index 7c6e2675a08f..b10a72d2641b 100644 --- a/docs/releases/1.4.16.txt +++ b/docs/releases/1.4.16.txt @@ -4,10 +4,14 @@ Django 1.4.16 release notes *Under development* -Django 1.4.16 fixes a regression in the 1.4.14 security release. +Django 1.4.16 fixes a regression in the 1.4.14 security release and a bug +preventing the use of some GEOS versions with GeoDjango. Bugfixes ======== * Allowed inline and hidden references to admin fields (`#23431 `_). + +* Fixed parsing of the GEOS version string + (`#20036 `_). From 3132edae41dbb13b924e98e8f0254e847155a050 Mon Sep 17 00:00:00 2001 From: Joseph Dougherty Date: Tue, 16 Sep 2014 23:30:11 -0400 Subject: [PATCH 321/367] [1.4.x] Fixed #23499 -- Error in built-in template tag "now" documentation Backport of ab8248361e0a7b4fc7684eaaa5891e16b8562683 from master. --- docs/ref/templates/builtins.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index 4b1a6be7cf29..e97aabcd2a72 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -744,11 +744,11 @@ Example:: It is {% now "jS F Y H:i" %} Note that you can backslash-escape a format string if you want to use the -"raw" value. In this example, "f" is backslash-escaped, because otherwise -"f" is a format string that displays the time. The "o" doesn't need to be -escaped, because it's not a format character:: +"raw" value. In this example, both "o" and "f" are backslash-escaped, because +otherwise each is a format string that displays the year and the time, +respectively:: - It is the {% now "jS o\f F" %} + It is the {% now "jS \o\f F" %} This would display as "It is the 4th of September". From df657a768248c07fdbdc950b08366f655d0f2f5d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 29 Sep 2014 19:31:29 -0400 Subject: [PATCH 322/367] [1.4.x] Required numpy < 1.9 for tests; refs #23489. Backport of 4743a94429 from stable/1.7.x --- tests/requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/requirements/base.txt b/tests/requirements/base.txt index c89533b0dfdb..11e47fc64d3d 100644 --- a/tests/requirements/base.txt +++ b/tests/requirements/base.txt @@ -1,6 +1,6 @@ docutils Markdown -numpy +numpy < 1.9 PIL py-bcrypt python-memcached From f58392d8d8b68e2e7777768f39941cd995ce16e4 Mon Sep 17 00:00:00 2001 From: Emmanuelle Delescolle Date: Sun, 5 Oct 2014 20:06:51 +0200 Subject: [PATCH 323/367] [1.4.x] Fixed #23604 -- Allowed related m2m fields to be references in the admin. Thanks Simon Charette for review. Backport of a24cf21722 from master --- django/contrib/admin/options.py | 5 +++++ docs/releases/1.4.16.txt | 7 +++++-- tests/regressiontests/admin_views/admin.py | 3 ++- tests/regressiontests/admin_views/models.py | 9 +++++++++ tests/regressiontests/admin_views/tests.py | 4 ++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 0bf9ede6cbff..12291605ee25 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -281,6 +281,11 @@ def to_field_allowed(self, request, to_field): except FieldDoesNotExist: return False + # Check whether this model is the origin of a M2M relationship + # in which case to_field has to be the pk on this model. + if opts.many_to_many and field.primary_key: + return True + # Make sure at least one of the models registered for this site # references this field through a FK or a M2M relationship. registered_models = set() diff --git a/docs/releases/1.4.16.txt b/docs/releases/1.4.16.txt index b10a72d2641b..23e9ecd380f9 100644 --- a/docs/releases/1.4.16.txt +++ b/docs/releases/1.4.16.txt @@ -4,12 +4,15 @@ Django 1.4.16 release notes *Under development* -Django 1.4.16 fixes a regression in the 1.4.14 security release and a bug -preventing the use of some GEOS versions with GeoDjango. +Django 1.4.16 fixes a couple regressions in the 1.4.14 security release and a +bug preventing the use of some GEOS versions with GeoDjango. Bugfixes ======== +* Allowed related many-to-many fields to be referenced in the admin + (`#23604 `_). + * Allowed inline and hidden references to admin fields (`#23431 `_). diff --git a/tests/regressiontests/admin_views/admin.py b/tests/regressiontests/admin_views/admin.py index f1147f6c47f6..d6c26ac5358b 100644 --- a/tests/regressiontests/admin_views/admin.py +++ b/tests/regressiontests/admin_views/admin.py @@ -28,7 +28,7 @@ AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated, RelatedPrepopulated, ReferencedByParent, ChildOfReferer, M2MReference, - ReferencedByInline, InlineReference, InlineReferer) + ReferencedByInline, InlineReference, InlineReferer, Ingredient) def callable_year(dt_value): @@ -656,6 +656,7 @@ class InlineRefererAdmin(admin.ModelAdmin): site.register(AdminOrderedAdminMethod, AdminOrderedAdminMethodAdmin) site.register(AdminOrderedCallable, AdminOrderedCallableAdmin) site.register(Color2, CustomTemplateFilterColorAdmin) +site.register(Ingredient) # Register core models we need in our tests from django.contrib.auth.models import User, Group diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py index 7378dcbe96f2..5546a09e6433 100644 --- a/tests/regressiontests/admin_views/models.py +++ b/tests/regressiontests/admin_views/models.py @@ -637,3 +637,12 @@ class InlineReference(models.Model): class InlineReferer(models.Model): refs = models.ManyToManyField(InlineReference) + +# Models for #23604 +class Recipe(models.Model): + name = models.CharField(max_length=20) + + +class Ingredient(models.Model): + name = models.CharField(max_length=20) + recipes = models.ManyToManyField('Recipe', related_name='ingredients') diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index eb2caf88781c..4a972d215df4 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -590,6 +590,10 @@ def test_disallowed_to_field(self): response = self.client.get("/test_admin/admin/admin_views/m2mreference/", {TO_FIELD_VAR: 'id'}) self.assertEqual(response.status_code, 200) + # #23604 - Specifying the pk of this model should be allowed when this model defines a m2m relationship + response = self.client.get("/test_admin/admin/admin_views/ingredient/", {TO_FIELD_VAR: 'id'}) + self.assertEqual(response.status_code, 200) + # #23329 - Specifying a field that is not refered by any other model directly registered # to this admin site but registered through inheritance should be allowed. response = self.client.get("/test_admin/admin/admin_views/referencedbyparent/", {TO_FIELD_VAR: 'id'}) From 643374bcf544dbcd1091539a9f3c832b5440fee2 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 10 Oct 2014 15:18:54 -0400 Subject: [PATCH 324/367] [1.4.x] Fixed #23631 -- Removed outdated note on MySQL timezone support. Thanks marfire for the report. Backport of 9db3653670 from master --- docs/ref/databases.txt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index 269197946ea9..ba797d4438a0 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -417,11 +417,6 @@ of whether ``unique=True`` is specified or not. DateTime fields ~~~~~~~~~~~~~~~ -MySQL does not have a timezone-aware column type. If an attempt is made to -store a timezone-aware ``time`` or ``datetime`` to a -:class:`~django.db.models.TimeField` or :class:`~django.db.models.DateTimeField` -respectively, a ``ValueError`` is raised rather than truncating data. - MySQL does not store fractions of seconds. Fractions of seconds are truncated to zero when the time is stored. From a92e386e2622fa16cc49debc840357b711d8a38f Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 22 Oct 2014 12:23:21 -0400 Subject: [PATCH 325/367] [1.4.x] Added release dates to release notes. Backport of 9dc782b631 from master --- docs/releases/1.4.16.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.4.16.txt b/docs/releases/1.4.16.txt index 23e9ecd380f9..c50cac4f5c67 100644 --- a/docs/releases/1.4.16.txt +++ b/docs/releases/1.4.16.txt @@ -2,7 +2,7 @@ Django 1.4.16 release notes =========================== -*Under development* +*October 22, 2014* Django 1.4.16 fixes a couple regressions in the 1.4.14 security release and a bug preventing the use of some GEOS versions with GeoDjango. From 151d6dbf9c04539bf557af7b380e2a941b7745e4 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Wed, 22 Oct 2014 12:36:19 -0400 Subject: [PATCH 326/367] [1.4.x] Bump version numbers for bugfix release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 366ffcae81e4..27f8c3a9b06a 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 16, 'alpha', 0) +VERSION = (1, 4, 16, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index effb3eb586de..70f69cb54bfb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.15' +version = '1.4.16' # The full version, including alpha/beta/rc tags. -release = '1.4.15' +release = '1.4.16' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index c8d78dc2506c..499c0a62350f 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.15.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.16.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 486b6ca3bc50c112ffd17e0f57bfe779a930eff3 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 22 Oct 2014 13:33:07 -0400 Subject: [PATCH 327/367] [1.4.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 27f8c3a9b06a..ed224bc432d9 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 16, 'final', 0) +VERSION = (1, 4, 17, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From a1dcd82b28c9c9ddb9fb790e7be7120ba339cd2e Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 4 Nov 2014 20:38:38 -0500 Subject: [PATCH 328/367] [1.4.x] Updated six to 1.8.0. Backport of 81477c91f6 from master --- django/utils/six.py | 240 +++++++++++++++++++++++++++++---------- docs/releases/1.4.17.txt | 10 ++ docs/releases/index.txt | 1 + 3 files changed, 189 insertions(+), 62 deletions(-) create mode 100644 docs/releases/1.4.17.txt diff --git a/django/utils/six.py b/django/utils/six.py index 26370d7741ed..ccc180c954b8 100644 --- a/django/utils/six.py +++ b/django/utils/six.py @@ -20,12 +20,15 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from __future__ import absolute_import + +import functools import operator import sys import types __author__ = "Benjamin Peterson " -__version__ = "1.6.1" +__version__ = "1.8.0" # Useful for very coarse version differentiation. @@ -83,11 +86,7 @@ def __init__(self, name): self.name = name def __get__(self, obj, tp): - try: - result = self._resolve() - except ImportError: - # See the nice big comment in MovedModule.__getattr__. - raise AttributeError("%s could not be imported " % self.name) + result = self._resolve() setattr(obj, self.name, result) # Invokes __set__. # This is a bit ugly, but it avoids running this again. delattr(obj.__class__, self.name) @@ -109,22 +108,7 @@ def _resolve(self): return _import_module(self.mod) def __getattr__(self, attr): - # It turns out many Python frameworks like to traverse sys.modules and - # try to load various attributes. This causes problems if this is a - # platform-specific module on the wrong platform, like _winreg on - # Unixes. Therefore, we silently pretend unimportable modules do not - # have any attributes. See issues #51, #53, #56, and #63 for the full - # tales of woe. - # - # First, if possible, avoid loading the module just to look at __file__, - # __name__, or __path__. - if (attr in ("__file__", "__name__", "__path__") and - self.mod not in sys.modules): - raise AttributeError(attr) - try: - _module = self._resolve() - except ImportError: - raise AttributeError(attr) + _module = self._resolve() value = getattr(_module, attr) setattr(self, attr, value) return value @@ -170,9 +154,72 @@ def _resolve(self): return getattr(module, self.attr) +class _SixMetaPathImporter(object): + """ + A meta path importer to import six.moves and its submodules. + + This class implements a PEP302 finder and loader. It should be compatible + with Python 2.5 and all existing versions of Python3 + """ + def __init__(self, six_module_name): + self.name = six_module_name + self.known_modules = {} + + def _add_module(self, mod, *fullnames): + for fullname in fullnames: + self.known_modules[self.name + "." + fullname] = mod + + def _get_module(self, fullname): + return self.known_modules[self.name + "." + fullname] + + def find_module(self, fullname, path=None): + if fullname in self.known_modules: + return self + return None + + def __get_module(self, fullname): + try: + return self.known_modules[fullname] + except KeyError: + raise ImportError("This loader does not know module " + fullname) + + def load_module(self, fullname): + try: + # in case of a reload + return sys.modules[fullname] + except KeyError: + pass + mod = self.__get_module(fullname) + if isinstance(mod, MovedModule): + mod = mod._resolve() + else: + mod.__loader__ = self + sys.modules[fullname] = mod + return mod + + def is_package(self, fullname): + """ + Return true, if the named module is a package. + + We need this method to get correct spec objects with + Python 3.4 (see PEP451) + """ + return hasattr(self.__get_module(fullname), "__path__") + + def get_code(self, fullname): + """Return None + + Required, if is_package is implemented""" + self.__get_module(fullname) # eventually raises ImportError + return None + get_source = get_code # same as get_code + +_importer = _SixMetaPathImporter(__name__) + class _MovedItems(_LazyModule): """Lazy loading of moved objects""" + __path__ = [] # mark as package _moved_attributes = [ @@ -180,11 +227,15 @@ class _MovedItems(_LazyModule): MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"), MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), + MovedAttribute("intern", "__builtin__", "sys"), MovedAttribute("map", "itertools", "builtins", "imap", "map"), MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"), MovedAttribute("reload_module", "__builtin__", "imp", "reload"), MovedAttribute("reduce", "__builtin__", "functools"), + MovedAttribute("shlex_quote", "pipes", "shlex", "quote"), MovedAttribute("StringIO", "StringIO", "io"), + MovedAttribute("UserDict", "UserDict", "collections"), + MovedAttribute("UserList", "UserList", "collections"), MovedAttribute("UserString", "UserString", "collections"), MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), @@ -194,12 +245,14 @@ class _MovedItems(_LazyModule): MovedModule("configparser", "ConfigParser"), MovedModule("copyreg", "copy_reg"), MovedModule("dbm_gnu", "gdbm", "dbm.gnu"), + MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread"), MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), MovedModule("http_cookies", "Cookie", "http.cookies"), MovedModule("html_entities", "htmlentitydefs", "html.entities"), MovedModule("html_parser", "HTMLParser", "html.parser"), MovedModule("http_client", "httplib", "http.client"), MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"), + MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"), MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"), MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"), MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), @@ -233,18 +286,19 @@ class _MovedItems(_LazyModule): MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), - MovedModule("xmlrpc_server", "xmlrpclib", "xmlrpc.server"), + MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"), MovedModule("winreg", "_winreg"), ] for attr in _moved_attributes: setattr(_MovedItems, attr.name, attr) if isinstance(attr, MovedModule): - sys.modules[__name__ + ".moves." + attr.name] = attr + _importer._add_module(attr, "moves." + attr.name) del attr _MovedItems._moved_attributes = _moved_attributes -moves = sys.modules[__name__ + ".moves"] = _MovedItems(__name__ + ".moves") +moves = _MovedItems(__name__ + ".moves") +_importer._add_module(moves, "moves") class Module_six_moves_urllib_parse(_LazyModule): @@ -268,6 +322,13 @@ class Module_six_moves_urllib_parse(_LazyModule): MovedAttribute("unquote_plus", "urllib", "urllib.parse"), MovedAttribute("urlencode", "urllib", "urllib.parse"), MovedAttribute("splitquery", "urllib", "urllib.parse"), + MovedAttribute("splittag", "urllib", "urllib.parse"), + MovedAttribute("splituser", "urllib", "urllib.parse"), + MovedAttribute("uses_fragment", "urlparse", "urllib.parse"), + MovedAttribute("uses_netloc", "urlparse", "urllib.parse"), + MovedAttribute("uses_params", "urlparse", "urllib.parse"), + MovedAttribute("uses_query", "urlparse", "urllib.parse"), + MovedAttribute("uses_relative", "urlparse", "urllib.parse"), ] for attr in _urllib_parse_moved_attributes: setattr(Module_six_moves_urllib_parse, attr.name, attr) @@ -275,7 +336,8 @@ class Module_six_moves_urllib_parse(_LazyModule): Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes -sys.modules[__name__ + ".moves.urllib_parse"] = sys.modules[__name__ + ".moves.urllib.parse"] = Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse") +_importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"), + "moves.urllib_parse", "moves.urllib.parse") class Module_six_moves_urllib_error(_LazyModule): @@ -293,7 +355,8 @@ class Module_six_moves_urllib_error(_LazyModule): Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes -sys.modules[__name__ + ".moves.urllib_error"] = sys.modules[__name__ + ".moves.urllib.error"] = Module_six_moves_urllib_error(__name__ + ".moves.urllib.error") +_importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"), + "moves.urllib_error", "moves.urllib.error") class Module_six_moves_urllib_request(_LazyModule): @@ -341,7 +404,8 @@ class Module_six_moves_urllib_request(_LazyModule): Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes -sys.modules[__name__ + ".moves.urllib_request"] = sys.modules[__name__ + ".moves.urllib.request"] = Module_six_moves_urllib_request(__name__ + ".moves.urllib.request") +_importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"), + "moves.urllib_request", "moves.urllib.request") class Module_six_moves_urllib_response(_LazyModule): @@ -360,7 +424,8 @@ class Module_six_moves_urllib_response(_LazyModule): Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes -sys.modules[__name__ + ".moves.urllib_response"] = sys.modules[__name__ + ".moves.urllib.response"] = Module_six_moves_urllib_response(__name__ + ".moves.urllib.response") +_importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"), + "moves.urllib_response", "moves.urllib.response") class Module_six_moves_urllib_robotparser(_LazyModule): @@ -376,22 +441,24 @@ class Module_six_moves_urllib_robotparser(_LazyModule): Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes -sys.modules[__name__ + ".moves.urllib_robotparser"] = sys.modules[__name__ + ".moves.urllib.robotparser"] = Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser") +_importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"), + "moves.urllib_robotparser", "moves.urllib.robotparser") class Module_six_moves_urllib(types.ModuleType): """Create a six.moves.urllib namespace that resembles the Python 3 namespace""" - parse = sys.modules[__name__ + ".moves.urllib_parse"] - error = sys.modules[__name__ + ".moves.urllib_error"] - request = sys.modules[__name__ + ".moves.urllib_request"] - response = sys.modules[__name__ + ".moves.urllib_response"] - robotparser = sys.modules[__name__ + ".moves.urllib_robotparser"] + __path__ = [] # mark as package + parse = _importer._get_module("moves.urllib_parse") + error = _importer._get_module("moves.urllib_error") + request = _importer._get_module("moves.urllib_request") + response = _importer._get_module("moves.urllib_response") + robotparser = _importer._get_module("moves.urllib_robotparser") def __dir__(self): return ['parse', 'error', 'request', 'response', 'robotparser'] - -sys.modules[__name__ + ".moves.urllib"] = Module_six_moves_urllib(__name__ + ".moves.urllib") +_importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"), + "moves.urllib") def add_move(move): @@ -418,11 +485,6 @@ def remove_move(name): _func_code = "__code__" _func_defaults = "__defaults__" _func_globals = "__globals__" - - _iterkeys = "keys" - _itervalues = "values" - _iteritems = "items" - _iterlists = "lists" else: _meth_func = "im_func" _meth_self = "im_self" @@ -432,11 +494,6 @@ def remove_move(name): _func_defaults = "func_defaults" _func_globals = "func_globals" - _iterkeys = "iterkeys" - _itervalues = "itervalues" - _iteritems = "iteritems" - _iterlists = "iterlists" - try: advance_iterator = next @@ -485,21 +542,37 @@ def next(self): get_function_globals = operator.attrgetter(_func_globals) -def iterkeys(d, **kw): - """Return an iterator over the keys of a dictionary.""" - return iter(getattr(d, _iterkeys)(**kw)) +if PY3: + def iterkeys(d, **kw): + return iter(d.keys(**kw)) + + def itervalues(d, **kw): + return iter(d.values(**kw)) + + def iteritems(d, **kw): + return iter(d.items(**kw)) + + def iterlists(d, **kw): + return iter(d.lists(**kw)) +else: + def iterkeys(d, **kw): + return iter(d.iterkeys(**kw)) + + def itervalues(d, **kw): + return iter(d.itervalues(**kw)) -def itervalues(d, **kw): - """Return an iterator over the values of a dictionary.""" - return iter(getattr(d, _itervalues)(**kw)) + def iteritems(d, **kw): + return iter(d.iteritems(**kw)) -def iteritems(d, **kw): - """Return an iterator over the (key, value) pairs of a dictionary.""" - return iter(getattr(d, _iteritems)(**kw)) + def iterlists(d, **kw): + return iter(d.iterlists(**kw)) -def iterlists(d, **kw): - """Return an iterator over the (key, [values]) pairs of a dictionary.""" - return iter(getattr(d, _iterlists)(**kw)) +_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") +_add_doc(itervalues, "Return an iterator over the values of a dictionary.") +_add_doc(iteritems, + "Return an iterator over the (key, value) pairs of a dictionary.") +_add_doc(iterlists, + "Return an iterator over the (key, [values]) pairs of a dictionary.") if PY3: @@ -545,6 +618,8 @@ def iterbytes(buf): def reraise(tp, value, tb=None): + if value is None: + value = tp() if value.__traceback__ is not tb: raise value.with_traceback(tb) raise value @@ -625,26 +700,67 @@ def write(data): _add_doc(reraise, """Reraise an exception.""") +if sys.version_info[0:2] < (3, 4): + def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES): + def wrapper(f): + f = functools.wraps(wrapped)(f) + f.__wrapped__ = wrapped + return f + return wrapper +else: + wraps = functools.wraps def with_metaclass(meta, *bases): """Create a base class with a metaclass.""" - return meta("NewBase", bases, {}) + # This requires a bit of explanation: the basic idea is to make a dummy + # metaclass for one level of class instantiation that replaces itself with + # the actual metaclass. + class metaclass(meta): + def __new__(cls, name, this_bases, d): + return meta(name, bases, d) + return type.__new__(metaclass, 'temporary_class', (), {}) + def add_metaclass(metaclass): """Class decorator for creating a class with a metaclass.""" def wrapper(cls): orig_vars = cls.__dict__.copy() - orig_vars.pop('__dict__', None) - orig_vars.pop('__weakref__', None) slots = orig_vars.get('__slots__') if slots is not None: if isinstance(slots, str): slots = [slots] for slots_var in slots: orig_vars.pop(slots_var) + orig_vars.pop('__dict__', None) + orig_vars.pop('__weakref__', None) return metaclass(cls.__name__, cls.__bases__, orig_vars) return wrapper +# Complete the moves implementation. +# This code is at the end of this module to speed up module loading. +# Turn this module into a package. +__path__ = [] # required for PEP 302 and PEP 451 +__package__ = __name__ # see PEP 366 @ReservedAssignment +if globals().get("__spec__") is not None: + __spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable +# Remove other six meta path importers, since they cause problems. This can +# happen if six is removed from sys.modules and then reloaded. (Setuptools does +# this for some reason.) +if sys.meta_path: + for i, importer in enumerate(sys.meta_path): + # Here's some real nastiness: Another "instance" of the six module might + # be floating around. Therefore, we can't use isinstance() to check for + # the six meta path importer, since the other six instance will have + # inserted an importer with different class. + if (type(importer).__name__ == "_SixMetaPathImporter" and + importer.name == __name__): + del sys.meta_path[i] + break + del i, importer +# Finally, add the importer to the meta path import hook. +sys.meta_path.append(_importer) + ### Additional customizations for Django ### diff --git a/docs/releases/1.4.17.txt b/docs/releases/1.4.17.txt new file mode 100644 index 000000000000..f8785bcea258 --- /dev/null +++ b/docs/releases/1.4.17.txt @@ -0,0 +1,10 @@ +=========================== +Django 1.4.17 release notes +=========================== + +*Under development* + +Django 1.4.17 ... + +Additionally, Django's vendored version of six, :mod:`django.utils.six`, has +been upgraded to the latest release (1.8.0). diff --git a/docs/releases/index.txt b/docs/releases/index.txt index bf7c0eceb45c..7c598a5388a7 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.17 1.4.16 1.4.15 1.4.14 From c83b024b37a448e3eece766a435cf148c806a22d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 13 Nov 2014 10:30:36 +0100 Subject: [PATCH 329/367] [1.4.x] Removed thread customizations of six which are now built-in. Backport of 7ef81b5cdd4c8eda12aa7786484a0bfde00aaaa4 from master --- django/utils/six.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/django/utils/six.py b/django/utils/six.py index ccc180c954b8..521a384d83c9 100644 --- a/django/utils/six.py +++ b/django/utils/six.py @@ -786,7 +786,3 @@ def assertRaisesRegex(self, *args, **kwargs): def assertRegex(self, *args, **kwargs): return getattr(self, _assertRegex)(*args, **kwargs) - - -add_move(MovedModule("_dummy_thread", "dummy_thread")) -add_move(MovedModule("_thread", "thread")) From 5940da16afb314c52cf52d4aebfedb77c6cc886b Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Sun, 16 Nov 2014 16:42:09 +0100 Subject: [PATCH 330/367] [1.4.x] Fixed #23754 -- Always allowed reference to the primary key in the admin This change allows dynamically created inlines "Add related" button to work correcly as long as their associated foreign key is pointing to the primary key of the related model. Thanks to amorce for the report, Julien Phalip for the initial patch, and Collin Anderson for the review. Backport of f9c4e14aeca7df79991bca8ac2d743953cbd095c from master --- django/contrib/admin/options.py | 9 ++++---- docs/releases/1.4.17.txt | 9 +++++++- tests/regressiontests/admin_views/admin.py | 5 +++-- tests/regressiontests/admin_views/models.py | 24 +++++++++++++++------ tests/regressiontests/admin_views/tests.py | 16 ++++++++------ 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 12291605ee25..a4bfd8821e26 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -281,9 +281,9 @@ def to_field_allowed(self, request, to_field): except FieldDoesNotExist: return False - # Check whether this model is the origin of a M2M relationship - # in which case to_field has to be the pk on this model. - if opts.many_to_many and field.primary_key: + # Always allow referencing the primary key since it's already possible + # to get this information from the change view URL. + if field.primary_key: return True # Make sure at least one of the models registered for this site @@ -294,8 +294,7 @@ def to_field_allowed(self, request, to_field): for inline in admin.inlines: registered_models.add(inline.model) - for related_object in (opts.get_all_related_objects(include_hidden=True) + - opts.get_all_related_many_to_many_objects()): + for related_object in opts.get_all_related_objects(include_hidden=True): related_model = related_object.model if (any(issubclass(model, related_model) for model in registered_models) and related_object.field.rel.get_related_field() == field): diff --git a/docs/releases/1.4.17.txt b/docs/releases/1.4.17.txt index f8785bcea258..62799417334a 100644 --- a/docs/releases/1.4.17.txt +++ b/docs/releases/1.4.17.txt @@ -4,7 +4,14 @@ Django 1.4.17 release notes *Under development* -Django 1.4.17 ... +Django 1.4.17 fixes a regression in the 1.4.14 security release. Additionally, Django's vendored version of six, :mod:`django.utils.six`, has been upgraded to the latest release (1.8.0). + +Bugfixes +======== + +* Fixed a regression with dynamically generated inlines and allowed field + references in the admin + (`#23754 `_). diff --git a/tests/regressiontests/admin_views/admin.py b/tests/regressiontests/admin_views/admin.py index d6c26ac5358b..a35d55634845 100644 --- a/tests/regressiontests/admin_views/admin.py +++ b/tests/regressiontests/admin_views/admin.py @@ -28,7 +28,7 @@ AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated, RelatedPrepopulated, ReferencedByParent, ChildOfReferer, M2MReference, - ReferencedByInline, InlineReference, InlineReferer, Ingredient) + ReferencedByInline, InlineReference, InlineReferer, Recipe, Ingredient, NotReferenced) def callable_year(dt_value): @@ -627,7 +627,6 @@ class InlineRefererAdmin(admin.ModelAdmin): site.register(UnorderedObject, UnorderedObjectAdmin) site.register(ReferencedByParent) site.register(ChildOfReferer) -site.register(M2MReference) site.register(ReferencedByInline) site.register(InlineReferer, InlineRefererAdmin) @@ -656,7 +655,9 @@ class InlineRefererAdmin(admin.ModelAdmin): site.register(AdminOrderedAdminMethod, AdminOrderedAdminMethodAdmin) site.register(AdminOrderedCallable, AdminOrderedCallableAdmin) site.register(Color2, CustomTemplateFilterColorAdmin) +site.register(Recipe) site.register(Ingredient) +site.register(NotReferenced) # Register core models we need in our tests from django.contrib.auth.models import User, Group diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py index 5546a09e6433..ff70c9f9d9eb 100644 --- a/tests/regressiontests/admin_views/models.py +++ b/tests/regressiontests/admin_views/models.py @@ -611,11 +611,13 @@ class UnorderedObject(models.Model): # Models for #23329 class ReferencedByParent(models.Model): - pass + name = models.CharField(max_length=20, unique=True) class ParentWithFK(models.Model): - fk = models.ForeignKey(ReferencedByParent) + fk = models.ForeignKey( + ReferencedByParent, to_field='name', related_name='hidden+', + ) class ChildOfReferer(ParentWithFK): @@ -625,13 +627,16 @@ class ChildOfReferer(ParentWithFK): class M2MReference(models.Model): ref = models.ManyToManyField('self') + # Models for #23431 class ReferencedByInline(models.Model): - pass + name = models.CharField(max_length=20, unique=True) class InlineReference(models.Model): - fk = models.ForeignKey(ReferencedByInline, related_name='hidden+') + fk = models.ForeignKey( + ReferencedByInline, to_field='name', related_name='hidden+', + ) class InlineReferer(models.Model): @@ -640,9 +645,14 @@ class InlineReferer(models.Model): # Models for #23604 class Recipe(models.Model): - name = models.CharField(max_length=20) + pass class Ingredient(models.Model): - name = models.CharField(max_length=20) - recipes = models.ManyToManyField('Recipe', related_name='ingredients') + recipes = models.ManyToManyField(Recipe) + + +# Model for #23839 +class NotReferenced(models.Model): + # Don't point any FK at this model. + pass diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index 4a972d215df4..4c699b477fcc 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -582,26 +582,30 @@ def test_disallowed_to_field(self): with self.assertRaises(DisallowedModelAdminToField): response = self.client.get("/test_admin/admin/admin_views/section/", {TO_FIELD_VAR: 'name'}) - # Specifying a field referenced by another model should be allowed. - response = self.client.get("/test_admin/admin/admin_views/section/", {TO_FIELD_VAR: 'id'}) + # #23839 - Primary key should always be allowed, even if the referenced model isn't registered. + response = self.client.get("/test_admin/admin/admin_views/notreferenced/", {TO_FIELD_VAR: 'id'}) self.assertEqual(response.status_code, 200) # Specifying a field referenced by another model though a m2m should be allowed. - response = self.client.get("/test_admin/admin/admin_views/m2mreference/", {TO_FIELD_VAR: 'id'}) + # XXX: We're not testing against a non-primary key field since the admin doesn't + # support it yet, ref #23862 + response = self.client.get("/test_admin/admin/admin_views/recipe/", {TO_FIELD_VAR: 'id'}) self.assertEqual(response.status_code, 200) - # #23604 - Specifying the pk of this model should be allowed when this model defines a m2m relationship + # #23604 - Specifying a field referenced through a reverse m2m relationship should be allowed. + # XXX: We're not testing against a non-primary key field since the admin doesn't + # support it yet, ref #23862 response = self.client.get("/test_admin/admin/admin_views/ingredient/", {TO_FIELD_VAR: 'id'}) self.assertEqual(response.status_code, 200) # #23329 - Specifying a field that is not refered by any other model directly registered # to this admin site but registered through inheritance should be allowed. - response = self.client.get("/test_admin/admin/admin_views/referencedbyparent/", {TO_FIELD_VAR: 'id'}) + response = self.client.get("/test_admin/admin/admin_views/referencedbyparent/", {TO_FIELD_VAR: 'name'}) self.assertEqual(response.status_code, 200) # #23431 - Specifying a field that is only refered to by a inline of a registered # model should be allowed. - response = self.client.get("/test_admin/admin/admin_views/referencedbyinline/", {TO_FIELD_VAR: 'id'}) + response = self.client.get("/test_admin/admin/admin_views/referencedbyinline/", {TO_FIELD_VAR: 'name'}) self.assertEqual(response.status_code, 200) def test_allowed_filtering_15103(self): From a25c444bc701b496f2b05f57fc3ec42cdac9dd85 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 2 Jan 2015 12:35:41 -0500 Subject: [PATCH 331/367] [1.4.x] Updated six to 1.9.0. Backport of 52f0b2b62262743d5f935ddae29428e661b5d8ea from master --- django/utils/six.py | 104 +++++++++++++++++++++++++++++++-------- docs/releases/1.4.17.txt | 2 +- 2 files changed, 85 insertions(+), 21 deletions(-) diff --git a/django/utils/six.py b/django/utils/six.py index 521a384d83c9..8bd322ebdcd5 100644 --- a/django/utils/six.py +++ b/django/utils/six.py @@ -1,6 +1,6 @@ """Utilities for writing code that runs on Python 2 and 3""" -# Copyright (c) 2010-2014 Benjamin Peterson +# Copyright (c) 2010-2015 Benjamin Peterson # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -23,12 +23,13 @@ from __future__ import absolute_import import functools +import itertools import operator import sys import types __author__ = "Benjamin Peterson " -__version__ = "1.8.0" +__version__ = "1.9.0" # Useful for very coarse version differentiation. @@ -88,8 +89,12 @@ def __init__(self, name): def __get__(self, obj, tp): result = self._resolve() setattr(obj, self.name, result) # Invokes __set__. - # This is a bit ugly, but it avoids running this again. - delattr(obj.__class__, self.name) + try: + # This is a bit ugly, but it avoids running this again by + # removing this descriptor. + delattr(obj.__class__, self.name) + except AttributeError: + pass return result @@ -554,6 +559,12 @@ def iteritems(d, **kw): def iterlists(d, **kw): return iter(d.lists(**kw)) + + viewkeys = operator.methodcaller("keys") + + viewvalues = operator.methodcaller("values") + + viewitems = operator.methodcaller("items") else: def iterkeys(d, **kw): return iter(d.iterkeys(**kw)) @@ -567,6 +578,12 @@ def iteritems(d, **kw): def iterlists(d, **kw): return iter(d.iterlists(**kw)) + viewkeys = operator.methodcaller("viewkeys") + + viewvalues = operator.methodcaller("viewvalues") + + viewitems = operator.methodcaller("viewitems") + _add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") _add_doc(itervalues, "Return an iterator over the values of a dictionary.") _add_doc(iteritems, @@ -593,6 +610,9 @@ def int2byte(i): import io StringIO = io.StringIO BytesIO = io.BytesIO + _assertCountEqual = "assertCountEqual" + _assertRaisesRegex = "assertRaisesRegex" + _assertRegex = "assertRegex" else: def b(s): return s @@ -605,14 +625,28 @@ def byte2int(bs): return ord(bs[0]) def indexbytes(buf, i): return ord(buf[i]) - def iterbytes(buf): - return (ord(byte) for byte in buf) + iterbytes = functools.partial(itertools.imap, ord) import StringIO StringIO = BytesIO = StringIO.StringIO + _assertCountEqual = "assertItemsEqual" + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" _add_doc(b, """Byte literal""") _add_doc(u, """Text literal""") +def assertCountEqual(self, *args, **kwargs): + return getattr(self, _assertCountEqual)(*args, **kwargs) + + +def assertRaisesRegex(self, *args, **kwargs): + return getattr(self, _assertRaisesRegex)(*args, **kwargs) + + +def assertRegex(self, *args, **kwargs): + return getattr(self, _assertRegex)(*args, **kwargs) + + if PY3: exec_ = getattr(moves.builtins, "exec") @@ -643,6 +677,21 @@ def exec_(_code_, _globs_=None, _locs_=None): """) +if sys.version_info[:2] == (3, 2): + exec_("""def raise_from(value, from_value): + if from_value is None: + raise value + raise value from from_value +""") +elif sys.version_info[:2] > (3, 2): + exec_("""def raise_from(value, from_value): + raise value from from_value +""") +else: + def raise_from(value, from_value): + raise value + + print_ = getattr(moves.builtins, "print", None) if print_ is None: def print_(*args, **kwargs): @@ -697,6 +746,14 @@ def write(data): write(sep) write(arg) write(end) +if sys.version_info[:2] < (3, 3): + _print = print_ + def print_(*args, **kwargs): + fp = kwargs.get("file", sys.stdout) + flush = kwargs.pop("flush", False) + _print(*args, **kwargs) + if flush and fp is not None: + fp.flush() _add_doc(reraise, """Reraise an exception.""") @@ -704,7 +761,7 @@ def write(data): def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, updated=functools.WRAPPER_UPDATES): def wrapper(f): - f = functools.wraps(wrapped)(f) + f = functools.wraps(wrapped, assigned, updated)(f) f.__wrapped__ = wrapped return f return wrapper @@ -737,6 +794,25 @@ def wrapper(cls): return metaclass(cls.__name__, cls.__bases__, orig_vars) return wrapper + +def python_2_unicode_compatible(klass): + """ + A decorator that defines __unicode__ and __str__ methods under Python 2. + Under Python 3 it does nothing. + + To support Python 2 and 3 with a single code base, define a __str__ method + returning text and apply this decorator to the class. + """ + if PY2: + if '__str__' not in klass.__dict__: + raise ValueError("@python_2_unicode_compatible cannot be applied " + "to %s because it doesn't define __str__()." % + klass.__name__) + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda self: self.__unicode__().encode('utf-8') + return klass + + # Complete the moves implementation. # This code is at the end of this module to speed up module loading. # Turn this module into a package. @@ -765,24 +841,12 @@ def wrapper(cls): ### Additional customizations for Django ### if PY3: - _assertRaisesRegex = "assertRaisesRegex" - _assertRegex = "assertRegex" memoryview = memoryview else: - _assertRaisesRegex = "assertRaisesRegexp" - _assertRegex = "assertRegexpMatches" - # memoryview and buffer are not stricly equivalent, but should be fine for + # memoryview and buffer are not strictly equivalent, but should be fine for # django core usage (mainly BinaryField). However, Jython doesn't support # buffer (see http://bugs.jython.org/issue1521), so we have to be careful. if sys.platform.startswith('java'): memoryview = memoryview else: memoryview = buffer - - -def assertRaisesRegex(self, *args, **kwargs): - return getattr(self, _assertRaisesRegex)(*args, **kwargs) - - -def assertRegex(self, *args, **kwargs): - return getattr(self, _assertRegex)(*args, **kwargs) diff --git a/docs/releases/1.4.17.txt b/docs/releases/1.4.17.txt index 62799417334a..a46cdd9bc0dd 100644 --- a/docs/releases/1.4.17.txt +++ b/docs/releases/1.4.17.txt @@ -7,7 +7,7 @@ Django 1.4.17 release notes Django 1.4.17 fixes a regression in the 1.4.14 security release. Additionally, Django's vendored version of six, :mod:`django.utils.six`, has -been upgraded to the latest release (1.8.0). +been upgraded to the latest release (1.9.0). Bugfixes ======== From 35dc639cd62e86a3e50fd0e305e12c42746d4891 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 2 Jan 2015 19:20:18 -0500 Subject: [PATCH 332/367] [1.4.x] Added dates to release notes. Backport of 15cd71ed24945ff7be5716580603fd65c0d45ef7 from master --- docs/releases/1.4.17.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.4.17.txt b/docs/releases/1.4.17.txt index a46cdd9bc0dd..6d2066bb6e86 100644 --- a/docs/releases/1.4.17.txt +++ b/docs/releases/1.4.17.txt @@ -2,7 +2,7 @@ Django 1.4.17 release notes =========================== -*Under development* +*January 2, 2015* Django 1.4.17 fixes a regression in the 1.4.14 security release. From 592187e11b934f83153133cd5b3a246a881359e7 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 2 Jan 2015 21:07:00 -0500 Subject: [PATCH 333/367] [1.4.x] Bumped version for 1.4.17 release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index ed224bc432d9..a7246289c6a2 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 17, 'alpha', 0) +VERSION = (1, 4, 17, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 70f69cb54bfb..cebde343c643 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.16' +version = '1.4.17' # The full version, including alpha/beta/rc tags. -release = '1.4.16' +release = '1.4.17' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index 499c0a62350f..d181f3bfdd74 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.16.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.17.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 52136afda4fa085189cb0655c60e44287706d273 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 2 Jan 2015 21:49:44 -0500 Subject: [PATCH 334/367] [1.4.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index a7246289c6a2..cfbcb02d953b 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 17, 'final', 0) +VERSION = (1, 4, 18, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 032ffade8acf6f79ff4cc7632d10df4619c32b72 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 2 Jan 2015 22:01:51 -0500 Subject: [PATCH 335/367] [1.4.x] Removed wheel generation from Makefile. --- extras/Makefile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/extras/Makefile b/extras/Makefile index ff14f404e2cb..562b3738bf41 100644 --- a/extras/Makefile +++ b/extras/Makefile @@ -1,9 +1,6 @@ -all: sdist bdist_wheel +all: sdist sdist: python setup.py sdist -bdist_wheel: - python -c "import setuptools;__file__='setup.py';exec(compile(open(__file__).read().replace('\\r\\n', '\\n'), __file__, 'exec'))" bdist_wheel - -.PHONY : sdist bdist_wheel +.PHONY : sdist From 2fd8054fda5b8ec304d675f82f4bd80c16e3fd95 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 5 Jan 2015 13:13:34 -0500 Subject: [PATCH 336/367] [1.4.x] Fixed #24081 -- Downgraded six to 1.8.0. This reverts commit a25c444bc701b496f2b05f57fc3ec42cdac9dd85. six 1.9+ requires Python 2.6 so this commit restores Python 2.5 compatibility. --- django/utils/six.py | 104 ++++++++------------------------------- docs/releases/1.4.18.txt | 14 ++++++ docs/releases/index.txt | 1 + 3 files changed, 35 insertions(+), 84 deletions(-) create mode 100644 docs/releases/1.4.18.txt diff --git a/django/utils/six.py b/django/utils/six.py index 8bd322ebdcd5..521a384d83c9 100644 --- a/django/utils/six.py +++ b/django/utils/six.py @@ -1,6 +1,6 @@ """Utilities for writing code that runs on Python 2 and 3""" -# Copyright (c) 2010-2015 Benjamin Peterson +# Copyright (c) 2010-2014 Benjamin Peterson # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -23,13 +23,12 @@ from __future__ import absolute_import import functools -import itertools import operator import sys import types __author__ = "Benjamin Peterson " -__version__ = "1.9.0" +__version__ = "1.8.0" # Useful for very coarse version differentiation. @@ -89,12 +88,8 @@ def __init__(self, name): def __get__(self, obj, tp): result = self._resolve() setattr(obj, self.name, result) # Invokes __set__. - try: - # This is a bit ugly, but it avoids running this again by - # removing this descriptor. - delattr(obj.__class__, self.name) - except AttributeError: - pass + # This is a bit ugly, but it avoids running this again. + delattr(obj.__class__, self.name) return result @@ -559,12 +554,6 @@ def iteritems(d, **kw): def iterlists(d, **kw): return iter(d.lists(**kw)) - - viewkeys = operator.methodcaller("keys") - - viewvalues = operator.methodcaller("values") - - viewitems = operator.methodcaller("items") else: def iterkeys(d, **kw): return iter(d.iterkeys(**kw)) @@ -578,12 +567,6 @@ def iteritems(d, **kw): def iterlists(d, **kw): return iter(d.iterlists(**kw)) - viewkeys = operator.methodcaller("viewkeys") - - viewvalues = operator.methodcaller("viewvalues") - - viewitems = operator.methodcaller("viewitems") - _add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") _add_doc(itervalues, "Return an iterator over the values of a dictionary.") _add_doc(iteritems, @@ -610,9 +593,6 @@ def int2byte(i): import io StringIO = io.StringIO BytesIO = io.BytesIO - _assertCountEqual = "assertCountEqual" - _assertRaisesRegex = "assertRaisesRegex" - _assertRegex = "assertRegex" else: def b(s): return s @@ -625,28 +605,14 @@ def byte2int(bs): return ord(bs[0]) def indexbytes(buf, i): return ord(buf[i]) - iterbytes = functools.partial(itertools.imap, ord) + def iterbytes(buf): + return (ord(byte) for byte in buf) import StringIO StringIO = BytesIO = StringIO.StringIO - _assertCountEqual = "assertItemsEqual" - _assertRaisesRegex = "assertRaisesRegexp" - _assertRegex = "assertRegexpMatches" _add_doc(b, """Byte literal""") _add_doc(u, """Text literal""") -def assertCountEqual(self, *args, **kwargs): - return getattr(self, _assertCountEqual)(*args, **kwargs) - - -def assertRaisesRegex(self, *args, **kwargs): - return getattr(self, _assertRaisesRegex)(*args, **kwargs) - - -def assertRegex(self, *args, **kwargs): - return getattr(self, _assertRegex)(*args, **kwargs) - - if PY3: exec_ = getattr(moves.builtins, "exec") @@ -677,21 +643,6 @@ def exec_(_code_, _globs_=None, _locs_=None): """) -if sys.version_info[:2] == (3, 2): - exec_("""def raise_from(value, from_value): - if from_value is None: - raise value - raise value from from_value -""") -elif sys.version_info[:2] > (3, 2): - exec_("""def raise_from(value, from_value): - raise value from from_value -""") -else: - def raise_from(value, from_value): - raise value - - print_ = getattr(moves.builtins, "print", None) if print_ is None: def print_(*args, **kwargs): @@ -746,14 +697,6 @@ def write(data): write(sep) write(arg) write(end) -if sys.version_info[:2] < (3, 3): - _print = print_ - def print_(*args, **kwargs): - fp = kwargs.get("file", sys.stdout) - flush = kwargs.pop("flush", False) - _print(*args, **kwargs) - if flush and fp is not None: - fp.flush() _add_doc(reraise, """Reraise an exception.""") @@ -761,7 +704,7 @@ def print_(*args, **kwargs): def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, updated=functools.WRAPPER_UPDATES): def wrapper(f): - f = functools.wraps(wrapped, assigned, updated)(f) + f = functools.wraps(wrapped)(f) f.__wrapped__ = wrapped return f return wrapper @@ -794,25 +737,6 @@ def wrapper(cls): return metaclass(cls.__name__, cls.__bases__, orig_vars) return wrapper - -def python_2_unicode_compatible(klass): - """ - A decorator that defines __unicode__ and __str__ methods under Python 2. - Under Python 3 it does nothing. - - To support Python 2 and 3 with a single code base, define a __str__ method - returning text and apply this decorator to the class. - """ - if PY2: - if '__str__' not in klass.__dict__: - raise ValueError("@python_2_unicode_compatible cannot be applied " - "to %s because it doesn't define __str__()." % - klass.__name__) - klass.__unicode__ = klass.__str__ - klass.__str__ = lambda self: self.__unicode__().encode('utf-8') - return klass - - # Complete the moves implementation. # This code is at the end of this module to speed up module loading. # Turn this module into a package. @@ -841,12 +765,24 @@ def python_2_unicode_compatible(klass): ### Additional customizations for Django ### if PY3: + _assertRaisesRegex = "assertRaisesRegex" + _assertRegex = "assertRegex" memoryview = memoryview else: - # memoryview and buffer are not strictly equivalent, but should be fine for + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + # memoryview and buffer are not stricly equivalent, but should be fine for # django core usage (mainly BinaryField). However, Jython doesn't support # buffer (see http://bugs.jython.org/issue1521), so we have to be careful. if sys.platform.startswith('java'): memoryview = memoryview else: memoryview = buffer + + +def assertRaisesRegex(self, *args, **kwargs): + return getattr(self, _assertRaisesRegex)(*args, **kwargs) + + +def assertRegex(self, *args, **kwargs): + return getattr(self, _assertRegex)(*args, **kwargs) diff --git a/docs/releases/1.4.18.txt b/docs/releases/1.4.18.txt new file mode 100644 index 000000000000..f328be24c32d --- /dev/null +++ b/docs/releases/1.4.18.txt @@ -0,0 +1,14 @@ +=========================== +Django 1.4.18 release notes +=========================== + +*Under development* + +Django 1.4.18 fixes a regression on Python 2.5 in the 1.4.17 release. + +Bugfixes +======== + +* To maintain compatibility with Python 2.5, Django's vendored version of six, + :mod:`django.utils.six`, has been downgraded to 1.8.0 which is the last + version to support Python 2.5. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 7c598a5388a7..98f69adc2075 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.18 1.4.17 1.4.16 1.4.15 From 113a8980f4f85c2013c7c4bd962daa463ec03554 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 25 Nov 2014 17:06:31 -0500 Subject: [PATCH 337/367] [1.4.x] Added stub release notes for security releases. --- docs/releases/1.4.18.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/releases/1.4.18.txt b/docs/releases/1.4.18.txt index f328be24c32d..e5df185cfb04 100644 --- a/docs/releases/1.4.18.txt +++ b/docs/releases/1.4.18.txt @@ -4,7 +4,8 @@ Django 1.4.18 release notes *Under development* -Django 1.4.18 fixes a regression on Python 2.5 in the 1.4.17 release. +Django 1.4.18 fixes several security issues in 1.4.17 as well as a regression +on Python 2.5 in the 1.4.17 release. Bugfixes ======== From 4f6fffc1dc429f1ad428ecf8e6620739e8837450 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Wed, 10 Sep 2014 11:06:19 -0600 Subject: [PATCH 338/367] [1.4.x] Stripped headers containing underscores to prevent spoofing in WSGI environ. This is a security fix. Disclosure following shortly. Thanks to Jedediah Smith for the report. --- django/core/servers/basehttp.py | 11 +++ docs/releases/1.4.18.txt | 24 +++++++ .../servers/servers/test_basehttp.py | 67 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tests/regressiontests/servers/servers/test_basehttp.py diff --git a/django/core/servers/basehttp.py b/django/core/servers/basehttp.py index 8d4ceabfc323..0ec5f98cb83a 100644 --- a/django/core/servers/basehttp.py +++ b/django/core/servers/basehttp.py @@ -199,6 +199,17 @@ def log_message(self, format, *args): sys.stderr.write(msg) + def get_environ(self): + # Strip all headers with underscores in the name before constructing + # the WSGI environ. This prevents header-spoofing based on ambiguity + # between underscores and dashes both normalized to underscores in WSGI + # env vars. Nginx and Apache 2.4+ both do this as well. + for k, v in self.headers.items(): + if '_' in k: + del self.headers[k] + + return super(WSGIRequestHandler, self).get_environ() + class AdminMediaHandler(handlers.StaticFilesHandler): """ diff --git a/docs/releases/1.4.18.txt b/docs/releases/1.4.18.txt index e5df185cfb04..55256cfdf334 100644 --- a/docs/releases/1.4.18.txt +++ b/docs/releases/1.4.18.txt @@ -7,6 +7,30 @@ Django 1.4.18 release notes Django 1.4.18 fixes several security issues in 1.4.17 as well as a regression on Python 2.5 in the 1.4.17 release. +WSGI header spoofing via underscore/dash conflation +=================================================== + +When HTTP headers are placed into the WSGI environ, they are normalized by +converting to uppercase, converting all dashes to underscores, and prepending +`HTTP_`. For instance, a header ``X-Auth-User`` would become +``HTTP_X_AUTH_USER`` in the WSGI environ (and thus also in Django's +``request.META`` dictionary). + +Unfortunately, this means that the WSGI environ cannot distinguish between +headers containing dashes and headers containing underscores: ``X-Auth-User`` +and ``X-Auth_User`` both become ``HTTP_X_AUTH_USER``. This means that if a +header is used in a security-sensitive way (for instance, passing +authentication information along from a front-end proxy), even if the proxy +carefully strips any incoming value for ``X-Auth-User``, an attacker may be +able to provide an ``X-Auth_User`` header (with underscore) and bypass this +protection. + +In order to prevent such attacks, both Nginx and Apache 2.4+ strip all headers +containing underscores from incoming requests by default. Django's built-in +development server now does the same. Django's development server is not +recommended for production use, but matching the behavior of common production +servers reduces the surface area for behavior changes during deployment. + Bugfixes ======== diff --git a/tests/regressiontests/servers/servers/test_basehttp.py b/tests/regressiontests/servers/servers/test_basehttp.py new file mode 100644 index 000000000000..6bca608d174e --- /dev/null +++ b/tests/regressiontests/servers/servers/test_basehttp.py @@ -0,0 +1,67 @@ +import sys + +from django.core.servers.basehttp import WSGIRequestHandler +from django.test import TestCase +from django.utils.six import BytesIO, StringIO + + +class Stub(object): + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +class WSGIRequestHandlerTestCase(TestCase): + + def test_strips_underscore_headers(self): + """WSGIRequestHandler ignores headers containing underscores. + + This follows the lead of nginx and Apache 2.4, and is to avoid + ambiguity between dashes and underscores in mapping to WSGI environ, + which can have security implications. + """ + def test_app(environ, start_response): + """A WSGI app that just reflects its HTTP environ.""" + start_response('200 OK', []) + http_environ_items = sorted( + '%s:%s' % (k, v) for k, v in environ.items() + if k.startswith('HTTP_') + ) + yield (','.join(http_environ_items)).encode('utf-8') + + rfile = BytesIO() + rfile.write(b"GET / HTTP/1.0\r\n") + rfile.write(b"Some-Header: good\r\n") + rfile.write(b"Some_Header: bad\r\n") + rfile.write(b"Other_Header: bad\r\n") + rfile.seek(0) + + # WSGIRequestHandler closes the output file; we need to make this a + # no-op so we can still read its contents. + class UnclosableBytesIO(BytesIO): + def close(self): + pass + + wfile = UnclosableBytesIO() + + def makefile(mode, *a, **kw): + if mode == 'rb': + return rfile + elif mode == 'wb': + return wfile + + request = Stub(makefile=makefile) + server = Stub(base_environ={}, get_app=lambda: test_app) + + # We don't need to check stderr, but we don't want it in test output + old_stderr = sys.stderr + sys.stderr = StringIO() + try: + # instantiating a handler runs the request as side effect + WSGIRequestHandler(request, '192.168.0.2', server) + finally: + sys.stderr = old_stderr + + wfile.seek(0) + body = list(wfile.readlines())[-1] + + self.assertEqual(body, b'HTTP_SOME_HEADER:good') From 4c241f1b710da6419d9dca160e80b23b82db7758 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 3 Dec 2014 16:14:00 -0500 Subject: [PATCH 339/367] [1.4.x] Fixed is_safe_url() to handle leading whitespace. This is a security fix. Disclosure following shortly. --- django/utils/http.py | 1 + docs/releases/1.4.18.txt | 14 ++++++++++++++ tests/regressiontests/utils/http.py | 7 ++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/django/utils/http.py b/django/utils/http.py index 2d404890b53d..e69a92b57883 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -234,6 +234,7 @@ def is_safe_url(url, host=None): """ if not url: return False + url = url.strip() # Chrome treats \ completely as / url = url.replace('\\', '/') # Chrome considers any URL with more than two slashes to be absolute, but diff --git a/docs/releases/1.4.18.txt b/docs/releases/1.4.18.txt index 55256cfdf334..2da42533bd34 100644 --- a/docs/releases/1.4.18.txt +++ b/docs/releases/1.4.18.txt @@ -31,6 +31,20 @@ development server now does the same. Django's development server is not recommended for production use, but matching the behavior of common production servers reduces the surface area for behavior changes during deployment. +Mitigated possible XSS attack via user-supplied redirect URLs +============================================================= + +Django relies on user input in some cases (e.g. +:func:`django.contrib.auth.views.login` and :doc:`i18n `) +to redirect the user to an "on success" URL. The security checks for these +redirects (namely ``django.util.http.is_safe_url()``) didn't strip leading +whitespace on the tested URL and as such considered URLs like +``\njavascript:...`` safe. If a developer relied on ``is_safe_url()`` to +provide safe redirect targets and put such a URL into a link, they could suffer +from a XSS attack. This bug doesn't affect Django currently, since we only put +this URL into the ``Location`` response header and browsers seem to ignore +JavaScript there. + Bugfixes ======== diff --git a/tests/regressiontests/utils/http.py b/tests/regressiontests/utils/http.py index 802b3fa88d56..3ec237ae6440 100644 --- a/tests/regressiontests/utils/http.py +++ b/tests/regressiontests/utils/http.py @@ -64,7 +64,7 @@ def test_base36(self): # bad input for n in [-1, sys.maxint+1, '1', 'foo', {1:2}, (1,2,3)]: self.assertRaises(ValueError, http.int_to_base36, n) - + for n in ['#', ' ']: self.assertRaises(ValueError, http.base36_to_int, n) @@ -73,7 +73,7 @@ def test_base36(self): # non-integer input self.assertRaises(TypeError, http.int_to_base36, 3.141) - + # more explicit output testing for n, b36 in [(0, '0'), (1, '1'), (42, '16'), (818469960, 'django')]: self.assertEqual(http.int_to_base36(n), b36) @@ -97,7 +97,8 @@ def test_is_safe_url(self): 'http:/\//example.com', 'http:\/example.com', 'http:/\example.com', - 'javascript:alert("XSS")'): + 'javascript:alert("XSS")' + '\njavascript:alert(x)'): self.assertFalse(http.is_safe_url(bad_url, host='testserver'), "%s should be blocked" % bad_url) for good_url in ('/view/?param=http://example.com', '/view/?param=https://example.com', From d020da6646c5142bc092247d218a3d1ce3e993f7 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 9 Dec 2014 15:32:03 -0500 Subject: [PATCH 340/367] [1.4.x] Prevented views.static.serve() from using large memory on large files. This is a security fix. Disclosure following shortly. --- django/views/static.py | 7 +++-- docs/releases/1.4.18.txt | 15 +++++++++++ .../regressiontests/views/media/long-line.txt | 1 + tests/regressiontests/views/tests/static.py | 26 +++++++++++++------ 4 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 tests/regressiontests/views/media/long-line.txt diff --git a/django/views/static.py b/django/views/static.py index ed237793e4c3..7677d7b71565 100644 --- a/django/views/static.py +++ b/django/views/static.py @@ -16,6 +16,9 @@ from django.utils.http import http_date, parse_http_date from django.utils.translation import ugettext as _, ugettext_noop +STREAM_CHUNK_SIZE = 4096 + + def serve(request, path, document_root=None, show_indexes=False): """ Serve static files below a given point in the directory structure. @@ -59,8 +62,8 @@ def serve(request, path, document_root=None, show_indexes=False): if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'), statobj.st_mtime, statobj.st_size): return HttpResponseNotModified(mimetype=mimetype) - with open(fullpath, 'rb') as f: - response = HttpResponse(f.read(), mimetype=mimetype) + f = open(fullpath, 'rb') + response = HttpResponse(iter(lambda: f.read(STREAM_CHUNK_SIZE), ''), mimetype=mimetype) response["Last-Modified"] = http_date(statobj.st_mtime) if stat.S_ISREG(statobj.st_mode): response["Content-Length"] = statobj.st_size diff --git a/docs/releases/1.4.18.txt b/docs/releases/1.4.18.txt index 2da42533bd34..7c431cfa5ae1 100644 --- a/docs/releases/1.4.18.txt +++ b/docs/releases/1.4.18.txt @@ -45,6 +45,21 @@ from a XSS attack. This bug doesn't affect Django currently, since we only put this URL into the ``Location`` response header and browsers seem to ignore JavaScript there. +Denial-of-service attack against ``django.views.static.serve`` +============================================================== + +In older versions of Django, the :func:`django.views.static.serve` view read +the files it served one line at a time. Therefore, a big file with no newlines +would result in memory usage equal to the size of that file. An attacker could +exploit this and launch a denial-of-service attack by simultaneously requesting +many large files. This view now reads the file in chunks to prevent large +memory usage. + +Note, however, that this view has always carried a warning that it is not +hardened for production use and should be used only as a development aid. Now +may be a good time to audit your project and serve your files in production +using a real front-end web server if you are not doing so. + Bugfixes ======== diff --git a/tests/regressiontests/views/media/long-line.txt b/tests/regressiontests/views/media/long-line.txt new file mode 100644 index 000000000000..b4e1948686f0 --- /dev/null +++ b/tests/regressiontests/views/media/long-line.txt @@ -0,0 +1 @@ +lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua hic tempora est veritatis culpa fugiat doloribus fugit in sed harum veniam porro eveniet maxime labore assumenda non illum possimus aut vero laudantium cum magni numquam dolorem explicabo quidem quasi nesciunt ipsum deleniti facilis neque similique nisi ad magnam accusamus quae provident dolor ab atque modi laboriosam fuga suscipit ea beatae ipsam consequatur saepe dolore nulla error quo iusto expedita nemo commodi aspernatur aliquam enim reiciendis rerum necessitatibus recusandae sint amet placeat temporibus autem iste deserunt esse dolores reprehenderit doloremque pariatur velit maiores repellat dignissimos asperiores aperiam alias a corporis id praesentium voluptatibus soluta voluptatem sit molestiae quas odio facere nostrum laborum incidunt eaque nihil ullam rem mollitia at cumque iure tenetur tempore totam repudiandae quisquam quod architecto officia vitae consectetur cupiditate molestias delectus voluptates earum et impedit quibusdam odit sequi perferendis eius perspiciatis eos quam quaerat officiis sunt ratione consequuntur quia quis obcaecati repellendus exercitationem vel minima libero blanditiis eligendi minus dicta voluptas excepturi nam eum inventore voluptatum ducimus sapiente dolorum itaque ipsa qui omnis debitis voluptate quos aliquid accusantium ex illo corrupti ut adipisci natus animi distinctio optio nobis unde similique excepturi vero culpa molestias fugit dolorum non amet iure inventore nihil suscipit explicabo veritatis officiis distinctio nesciunt saepe incidunt reprehenderit porro vitae cumque alias ut deleniti expedita ratione odio magnam eligendi a nostrum laborum minus esse sit libero quaerat qui id illo voluptates soluta neque odit dolore consectetur ducimus nulla est nisi impedit quia sapiente ullam temporibus ipsam repudiandae delectus fugiat blanditiis maxime voluptatibus aspernatur ea ipsum quisquam sunt eius ipsa accusantium enim corporis earum sed sequi dicta accusamus dignissimos illum pariatur quos aut reiciendis obcaecati perspiciatis consequuntur nam modi praesentium cum repellat possimus iste atque quidem architecto recusandae harum eaque sint quae optio voluptate quod quasi beatae magni necessitatibus facilis aperiam repellendus nemo aliquam et quibusdam debitis itaque cupiditate laboriosam unde tempora commodi laudantium in placeat ad vel maiores aliquid hic tempore provident quas officia adipisci rem corrupti iusto natus eum rerum at ex quam eveniet totam dolor assumenda error eos doloribus labore fuga facere deserunt ab dolores consequatur veniam animi exercitationem asperiores mollitia minima numquam voluptatem voluptatum nobis molestiae voluptas omnis velit quis quo tenetur perferendis autem dolorem doloremque sequi vitae laudantium magnam quae adipisci expedita doloribus minus perferendis vero animi at quos iure facere nihil veritatis consectetur similique porro tenetur nobis fugiat quo ducimus qui soluta maxime placeat error sunt ullam quaerat provident eos minima ab harum ratione inventore unde sint dolorum deserunt veniam laborum quasi suscipit facilis eveniet voluptatibus est ipsum sapiente omnis vel repellat perspiciatis illo voluptate aliquid magni alias modi odit ea a voluptatem reiciendis recusandae mollitia eius distinctio amet atque voluptates obcaecati deleniti eligendi commodi debitis dolore laboriosam nam illum pariatur earum exercitationem velit in quas explicabo fugit asperiores itaque quam sit dolorem beatae quod cumque necessitatibus tempora dolores hic aperiam ex tempore ut neque maiores ad dicta voluptatum eum officia assumenda reprehenderit nisi cum molestiae et iusto quidem consequuntur repellendus saepe corrupti numquam culpa rerum incidunt dolor impedit iste sed non praesentium ipsam consequatur eaque possimus quia quibusdam excepturi aspernatur voluptas quisquam autem molestias aliquam corporis delectus nostrum labore nesciunt blanditiis quis enim accusamus nulla architecto fuga natus ipsa repudiandae cupiditate temporibus aut libero optio id officiis esse dignissimos odio totam doloremque accusantium nemo rem repudiandae aliquam accusamus autem minima reiciendis debitis quis ut ducimus quas dolore ratione neque velit repellat natus est error ea nam consequuntur rerum excepturi aspernatur quaerat cumque voluptatibus rem quasi eos unde architecto animi sunt veritatis delectus nulla at iusto repellendus dolorum obcaecati commodi earum assumenda quisquam cum officiis modi ab tempora harum vitae voluptatem explicabo alias maxime nostrum iure consectetur incidunt laudantium distinctio deleniti iste facere fugit libero illo nobis expedita perferendis labore similique beatae sint dicta dignissimos sapiente dolor soluta perspiciatis aut ad illum facilis totam necessitatibus eveniet temporibus reprehenderit quidem fugiat magni dolorem doloribus quibusdam eligendi fuga quae recusandae eum amet dolores asperiores voluptas inventore officia sit vel id vero nihil optio nisi magnam deserunt odit corrupti adipisci aliquid odio enim pariatur cupiditate suscipit voluptatum corporis porro mollitia eaque quia non quod consequatur ipsa nesciunt itaque exercitationem molestias molestiae atque in numquam quo ipsam nemo ex tempore ipsum saepe esse sed veniam a voluptates placeat accusantium quos laboriosam voluptate provident hic sequi quam doloremque eius impedit omnis possimus laborum tenetur praesentium et minus ullam blanditiis culpa qui aperiam maiores quidem numquam nulla diff --git a/tests/regressiontests/views/tests/static.py b/tests/regressiontests/views/tests/static.py index 3088a86eabee..278eaf5f7aa4 100644 --- a/tests/regressiontests/views/tests/static.py +++ b/tests/regressiontests/views/tests/static.py @@ -7,6 +7,7 @@ from django.conf.urls.static import static from django.test import TestCase from django.http import HttpResponseNotModified +from django.views.static import STREAM_CHUNK_SIZE from .. import urls from ..urls import media_dir @@ -29,10 +30,19 @@ def test_serve(self): for filename in media_files: response = self.client.get('/views/%s/%s' % (self.prefix, filename)) file_path = path.join(media_dir, filename) - self.assertEqual(open(file_path).read(), response.content) - self.assertEqual(len(response.content), int(response['Content-Length'])) + content = response.content + self.assertEqual(open(file_path).read(), content) + self.assertEqual(len(content), int(response['Content-Length'])) self.assertEqual(mimetypes.guess_type(file_path)[1], response.get('Content-Encoding', None)) + def test_chunked(self): + "The static view should stream files in chunks to avoid large memory usage" + response = self.client.get('/views/%s/%s' % (self.prefix, 'long-line.txt')) + first_chunk = iter(response).next() + self.assertEqual(len(first_chunk), STREAM_CHUNK_SIZE) + second_chunk = response.next() + self.assertEqual(len(second_chunk), 1451) + def test_unknown_mime_type(self): response = self.client.get('/views/%s/file.unknown' % self.prefix) self.assertEqual('application/octet-stream', response['Content-Type']) @@ -71,9 +81,9 @@ def test_invalid_if_modified_since(self): response = self.client.get('/views/%s/%s' % (self.prefix, file_name), HTTP_IF_MODIFIED_SINCE=invalid_date) file = open(path.join(media_dir, file_name)) - self.assertEqual(file.read(), response.content) - self.assertEqual(len(response.content), - int(response['Content-Length'])) + content = response.content + self.assertEqual(file.read(), content) + self.assertEqual(len(content), int(response['Content-Length'])) def test_invalid_if_modified_since2(self): """Handle even more bogus If-Modified-Since values gracefully @@ -86,9 +96,9 @@ def test_invalid_if_modified_since2(self): response = self.client.get('/views/%s/%s' % (self.prefix, file_name), HTTP_IF_MODIFIED_SINCE=invalid_date) file = open(path.join(media_dir, file_name)) - self.assertEqual(file.read(), response.content) - self.assertEqual(len(response.content), - int(response['Content-Length'])) + content = response.content + self.assertEqual(file.read(), content) + self.assertEqual(len(content), int(response['Content-Length'])) class StaticHelperTest(StaticTests): From 88b7957b34b2305ece54a3aab57a87701279a1d8 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 13 Jan 2015 13:08:57 -0500 Subject: [PATCH 341/367] [1.4.x] Added dates to release notes. --- docs/releases/1.4.18.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/1.4.18.txt b/docs/releases/1.4.18.txt index 7c431cfa5ae1..b154d872fa8c 100644 --- a/docs/releases/1.4.18.txt +++ b/docs/releases/1.4.18.txt @@ -2,7 +2,7 @@ Django 1.4.18 release notes =========================== -*Under development* +*January 13, 2015* Django 1.4.18 fixes several security issues in 1.4.17 as well as a regression on Python 2.5 in the 1.4.17 release. From bd9dcd226b5b11fa40668553d26ce06000b3be75 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 13 Jan 2015 13:14:08 -0500 Subject: [PATCH 342/367] [1.4.x] Bumped version for 1.4.18 release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index cfbcb02d953b..e4e9e6c4aeaf 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 18, 'alpha', 0) +VERSION = (1, 4, 18, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index cebde343c643..154e48e2febf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.17' +version = '1.4.18' # The full version, including alpha/beta/rc tags. -release = '1.4.17' +release = '1.4.18' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index d181f3bfdd74..0694ee28e842 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.17.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.18.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 4296a1da8b2acf118814669fa045a3c353c8892e Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 13 Jan 2015 14:16:07 -0500 Subject: [PATCH 343/367] [1.4.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index e4e9e6c4aeaf..b858b1b8099c 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 18, 'final', 0) +VERSION = (1, 4, 19, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 99e6ac77f2b5c701896dd83ce99ff12278cdbeef Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 14 Jan 2015 13:46:15 -0500 Subject: [PATCH 344/367] [1.4.x] Fixed a static view test on Windows. Backport of a6f144fd4fee0090de3a99b1f50a4142722e7946 from master --- tests/regressiontests/views/tests/static.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/regressiontests/views/tests/static.py b/tests/regressiontests/views/tests/static.py index 278eaf5f7aa4..de927a20959f 100644 --- a/tests/regressiontests/views/tests/static.py +++ b/tests/regressiontests/views/tests/static.py @@ -41,7 +41,8 @@ def test_chunked(self): first_chunk = iter(response).next() self.assertEqual(len(first_chunk), STREAM_CHUNK_SIZE) second_chunk = response.next() - self.assertEqual(len(second_chunk), 1451) + # strip() to prevent OS line endings from causing differences + self.assertEqual(len(second_chunk.strip()), 1449) def test_unknown_mime_type(self): response = self.client.get('/views/%s/file.unknown' % self.prefix) From 9435474068c2ae2261105adbbe7aebdb80b778f3 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 19 Jan 2015 12:08:20 -0500 Subject: [PATCH 345/367] [1.4.x] Designated Django 1.8 as the next LTS. Backport of c38db4d7e072e9a5002cb4897d9104e5eaa292ed from master --- docs/internals/release-process.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/internals/release-process.txt b/docs/internals/release-process.txt index 3b8e3c2a98eb..ea223a4b8644 100644 --- a/docs/internals/release-process.txt +++ b/docs/internals/release-process.txt @@ -145,7 +145,11 @@ the pace of releases afterwards. The follow releases have been designated for long-term support: - * Django 1.4, supported until at least March 2015. +* Django 1.8, supported for at least 3 years after its release (scheduled for + April 2015). +* Django 1.4, supported for 6 months after the release of Django 1.8. As + Django 1.8 is scheduled to be released around April 2015, support for 1.4 + will end around October 2015. .. _release-process: From 1e39d0f6280abf34c7719db5e7ed1c333f5e5919 Mon Sep 17 00:00:00 2001 From: Benjamin Richter Date: Sun, 25 Jan 2015 23:22:46 +0100 Subject: [PATCH 346/367] [1.4.x] Fixed #24158 -- Allowed GZipMiddleware to work with streaming responses Backport of django.utils.text.compress_sequence and fix for django.middleware.gzip.GZipMiddleware when using iterators as response.content. --- django/middleware/gzip.py | 25 +++++++++++------ django/utils/text.py | 33 +++++++++++++++++++++++ docs/releases/1.4.19.txt | 16 +++++++++++ docs/releases/index.txt | 1 + tests/regressiontests/middleware/tests.py | 13 +++++++++ 5 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 docs/releases/1.4.19.txt diff --git a/django/middleware/gzip.py b/django/middleware/gzip.py index 69f938cf0a41..eb4d8bff42ef 100644 --- a/django/middleware/gzip.py +++ b/django/middleware/gzip.py @@ -1,6 +1,6 @@ import re -from django.utils.text import compress_string +from django.utils.text import compress_string, compress_sequence from django.utils.cache import patch_vary_headers re_accepts_gzip = re.compile(r'\bgzip\b') @@ -12,8 +12,9 @@ class GZipMiddleware(object): on the Accept-Encoding header. """ def process_response(self, request, response): + # The response object can tell us whether content is a string or an iterable # It's not worth attempting to compress really short responses. - if len(response.content) < 200: + if not response._base_content_is_iter and len(response.content) < 200: return response patch_vary_headers(response, ('Accept-Encoding',)) @@ -32,15 +33,23 @@ def process_response(self, request, response): if not re_accepts_gzip.search(ae): return response - # Return the compressed content only if it's actually shorter. - compressed_content = compress_string(response.content) - if len(compressed_content) >= len(response.content): - return response + # The response object can tell us whether content is a string or an iterable + if response._base_content_is_iter: + # If the response content is iterable we don't know the length, so delete the header. + del response['Content-Length'] + # Wrap the response content in a streaming gzip iterator (direct access to inner response._container) + response.content = compress_sequence(response._container) + else: + # Return the compressed content only if it's actually shorter. + compressed_content = compress_string(response.content) + if len(compressed_content) >= len(response.content): + return response + response.content = compressed_content + response['Content-Length'] = str(len(response.content)) if response.has_header('ETag'): response['ETag'] = re.sub('"$', ';gzip"', response['ETag']) - response.content = compressed_content response['Content-Encoding'] = 'gzip' - response['Content-Length'] = str(len(response.content)) + return response diff --git a/django/utils/text.py b/django/utils/text.py index eaafb96d7cca..8e43dc96521b 100644 --- a/django/utils/text.py +++ b/django/utils/text.py @@ -286,6 +286,39 @@ def compress_string(s): ustring_re = re.compile(u"([\u0080-\uffff])") +# Backported from django 1.5 +class StreamingBuffer(object): + def __init__(self): + self.vals = [] + + def write(self, val): + self.vals.append(val) + + def read(self): + ret = ''.join(self.vals) + self.vals = [] + return ret + + def flush(self): + return + + def close(self): + return + +# Backported from django 1.5 +# Like compress_string, but for iterators of strings. +def compress_sequence(sequence): + buf = StreamingBuffer() + zfile = GzipFile(mode='wb', compresslevel=6, fileobj=buf) + # Output headers... + yield buf.read() + for item in sequence: + zfile.write(item) + zfile.flush() + yield buf.read() + zfile.close() + yield buf.read() + def javascript_quote(s, quote_double_quotes=False): def fix(match): diff --git a/docs/releases/1.4.19.txt b/docs/releases/1.4.19.txt new file mode 100644 index 000000000000..da813fa7eb92 --- /dev/null +++ b/docs/releases/1.4.19.txt @@ -0,0 +1,16 @@ +=========================== +Django 1.4.19 release notes +=========================== + +*Under development* + +Django 1.4.19 fixes a regression in the 1.4.18 security release. + +Bugfixes +======== + +* ``GZipMiddleware`` now supports streaming responses. As part of the 1.4.18 + security release, the ``django.views.static.serve()`` function was altered + to stream the files it serves. Unfortunately, the ``GZipMiddleware`` consumed + the stream prematurely and prevented files from being served properly + (`#24158 `_). diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 98f69adc2075..58b32f05c655 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.19 1.4.18 1.4.17 1.4.16 diff --git a/tests/regressiontests/middleware/tests.py b/tests/regressiontests/middleware/tests.py index 138ee50e4382..87b19fb6dae5 100644 --- a/tests/regressiontests/middleware/tests.py +++ b/tests/regressiontests/middleware/tests.py @@ -514,6 +514,7 @@ class GZipMiddlewareTest(TestCase): short_string = "This string is too short to be worth compressing." compressible_string = 'a' * 500 uncompressible_string = ''.join(chr(random.randint(0, 255)) for _ in xrange(500)) + iterator_as_content = iter(compressible_string) def setUp(self): self.req = HttpRequest() @@ -589,6 +590,18 @@ def test_no_compress_uncompressible_response(self): self.assertEqual(r.content, self.uncompressible_string) self.assertEqual(r.get('Content-Encoding'), None) + def test_streaming_compression(self): + """ + Tests that iterators as response content return a compressed stream without consuming + the whole response.content while doing so. + See #24158. + """ + self.resp.content = self.iterator_as_content + r = GZipMiddleware().process_response(self.req, self.resp) + self.assertEqual(self.decompress(''.join(r.content)), self.compressible_string) + self.assertEqual(r.get('Content-Encoding'), 'gzip') + self.assertEqual(r.get('Content-Length'), None) + class ETagGZipMiddlewareTest(TestCase): """ From 7dd4c5221a0975165c95142ba749553c7d7cff32 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 27 Jan 2015 11:54:46 -0500 Subject: [PATCH 347/367] [1.4.x] Bumped version for 1.4.19 release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- docs/releases/1.4.19.txt | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index b858b1b8099c..d33a5fd4cea4 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 19, 'alpha', 0) +VERSION = (1, 4, 19, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 154e48e2febf..bf2f41af918e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.18' +version = '1.4.19' # The full version, including alpha/beta/rc tags. -release = '1.4.18' +release = '1.4.19' # The next version to be released django_next_version = '1.5' diff --git a/docs/releases/1.4.19.txt b/docs/releases/1.4.19.txt index da813fa7eb92..992fc6c74021 100644 --- a/docs/releases/1.4.19.txt +++ b/docs/releases/1.4.19.txt @@ -2,7 +2,7 @@ Django 1.4.19 release notes =========================== -*Under development* +*January 27, 2015* Django 1.4.19 fixes a regression in the 1.4.18 security release. diff --git a/setup.py b/setup.py index 0694ee28e842..a7e7b05bc85d 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.18.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.19.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 4376d6ef7bc451278abc81476ea3d59f1fa63544 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 27 Jan 2015 12:26:26 -0500 Subject: [PATCH 348/367] [1.4.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index d33a5fd4cea4..c3880969b334 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 19, 'final', 0) +VERSION = (1, 4, 20, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From e60557c249d33e100008cc30890cde2daeb677bb Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 28 Jan 2015 12:03:05 -0500 Subject: [PATCH 349/367] [1.4.x] Fixed #24238 -- Removed unused WSGIRequestHandler.get_environ() Also moved the test as it wasn't running. --- django/core/servers/basehttp.py | 33 --------- .../servers/servers/test_basehttp.py | 67 ------------------- tests/regressiontests/servers/tests.py | 67 ++++++++++++++++++- 3 files changed, 66 insertions(+), 101 deletions(-) delete mode 100644 tests/regressiontests/servers/servers/test_basehttp.py diff --git a/django/core/servers/basehttp.py b/django/core/servers/basehttp.py index 0ec5f98cb83a..d570657cd539 100644 --- a/django/core/servers/basehttp.py +++ b/django/core/servers/basehttp.py @@ -138,39 +138,6 @@ def __init__(self, *args, **kwargs): self.style = color_style() super(WSGIRequestHandler, self).__init__(*args, **kwargs) - def get_environ(self): - env = self.server.base_environ.copy() - env['SERVER_PROTOCOL'] = self.request_version - env['REQUEST_METHOD'] = self.command - if '?' in self.path: - path,query = self.path.split('?',1) - else: - path,query = self.path,'' - - env['PATH_INFO'] = urllib.unquote(path) - env['QUERY_STRING'] = query - env['REMOTE_ADDR'] = self.client_address[0] - - if self.headers.typeheader is None: - env['CONTENT_TYPE'] = self.headers.type - else: - env['CONTENT_TYPE'] = self.headers.typeheader - - length = self.headers.getheader('content-length') - if length: - env['CONTENT_LENGTH'] = length - - for h in self.headers.headers: - k,v = h.split(':',1) - k=k.replace('-','_').upper(); v=v.strip() - if k in env: - continue # skip content length, type,etc. - if 'HTTP_'+k in env: - env['HTTP_'+k] += ','+v # comma-separate multiple headers - else: - env['HTTP_'+k] = v - return env - def log_message(self, format, *args): # Don't bother logging requests for admin images or the favicon. if (self.path.startswith(self.admin_media_prefix) diff --git a/tests/regressiontests/servers/servers/test_basehttp.py b/tests/regressiontests/servers/servers/test_basehttp.py deleted file mode 100644 index 6bca608d174e..000000000000 --- a/tests/regressiontests/servers/servers/test_basehttp.py +++ /dev/null @@ -1,67 +0,0 @@ -import sys - -from django.core.servers.basehttp import WSGIRequestHandler -from django.test import TestCase -from django.utils.six import BytesIO, StringIO - - -class Stub(object): - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - - -class WSGIRequestHandlerTestCase(TestCase): - - def test_strips_underscore_headers(self): - """WSGIRequestHandler ignores headers containing underscores. - - This follows the lead of nginx and Apache 2.4, and is to avoid - ambiguity between dashes and underscores in mapping to WSGI environ, - which can have security implications. - """ - def test_app(environ, start_response): - """A WSGI app that just reflects its HTTP environ.""" - start_response('200 OK', []) - http_environ_items = sorted( - '%s:%s' % (k, v) for k, v in environ.items() - if k.startswith('HTTP_') - ) - yield (','.join(http_environ_items)).encode('utf-8') - - rfile = BytesIO() - rfile.write(b"GET / HTTP/1.0\r\n") - rfile.write(b"Some-Header: good\r\n") - rfile.write(b"Some_Header: bad\r\n") - rfile.write(b"Other_Header: bad\r\n") - rfile.seek(0) - - # WSGIRequestHandler closes the output file; we need to make this a - # no-op so we can still read its contents. - class UnclosableBytesIO(BytesIO): - def close(self): - pass - - wfile = UnclosableBytesIO() - - def makefile(mode, *a, **kw): - if mode == 'rb': - return rfile - elif mode == 'wb': - return wfile - - request = Stub(makefile=makefile) - server = Stub(base_environ={}, get_app=lambda: test_app) - - # We don't need to check stderr, but we don't want it in test output - old_stderr = sys.stderr - sys.stderr = StringIO() - try: - # instantiating a handler runs the request as side effect - WSGIRequestHandler(request, '192.168.0.2', server) - finally: - sys.stderr = old_stderr - - wfile.seek(0) - body = list(wfile.readlines())[-1] - - self.assertEqual(body, b'HTTP_SOME_HEADER:good') diff --git a/tests/regressiontests/servers/tests.py b/tests/regressiontests/servers/tests.py index d237c83c659e..e0a66f675f2c 100644 --- a/tests/regressiontests/servers/tests.py +++ b/tests/regressiontests/servers/tests.py @@ -2,6 +2,7 @@ Tests for django.core.servers. """ import os +import sys from urlparse import urljoin import urllib2 @@ -10,8 +11,10 @@ from django.core.exceptions import ImproperlyConfigured from django.test import TestCase, LiveServerTestCase from django.core.handlers.wsgi import WSGIHandler -from django.core.servers.basehttp import AdminMediaHandler, WSGIServerException +from django.core.servers.basehttp import ( + AdminMediaHandler, WSGIRequestHandler, WSGIServerException) from django.test.utils import override_settings +from django.utils.six import BytesIO, StringIO from .models import Person @@ -213,3 +216,65 @@ def test_database_writes(self): self.urlopen('/create_model_instance/') names = [person.name for person in Person.objects.all()] self.assertEquals(names, ['jane', 'robert', 'emily']) + + +class Stub(object): + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +class WSGIRequestHandlerTestCase(TestCase): + + def test_strips_underscore_headers(self): + """WSGIRequestHandler ignores headers containing underscores. + + This follows the lead of nginx and Apache 2.4, and is to avoid + ambiguity between dashes and underscores in mapping to WSGI environ, + which can have security implications. + """ + def test_app(environ, start_response): + """A WSGI app that just reflects its HTTP environ.""" + start_response('200 OK', []) + http_environ_items = sorted( + '%s:%s' % (k, v) for k, v in environ.items() + if k.startswith('HTTP_') + ) + yield (','.join(http_environ_items)).encode('utf-8') + + rfile = BytesIO() + rfile.write("GET / HTTP/1.0\r\n") + rfile.write("Some-Header: good\r\n") + rfile.write("Some_Header: bad\r\n") + rfile.write("Other_Header: bad\r\n") + rfile.seek(0) + + # WSGIRequestHandler closes the output file; we need to make this a + # no-op so we can still read its contents. + class UnclosableBytesIO(BytesIO): + def close(self): + pass + + wfile = UnclosableBytesIO() + + def makefile(mode, *a, **kw): + if mode == 'rb': + return rfile + elif mode == 'wb': + return wfile + + request = Stub(makefile=makefile) + server = Stub(base_environ={}, get_app=lambda: test_app) + + # We don't need to check stderr, but we don't want it in test output + old_stderr = sys.stderr + sys.stderr = StringIO() + try: + # instantiating a handler runs the request as side effect + WSGIRequestHandler(request, '192.168.0.2', server) + finally: + sys.stderr = old_stderr + + wfile.seek(0) + body = list(wfile.readlines())[-1] + + self.assertEqual(body, 'HTTP_SOME_HEADER:good') From 785e57e296ed8f920789eb120da80944ff4f17fd Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Fri, 6 Feb 2015 20:20:02 -0700 Subject: [PATCH 350/367] [1.4.x] Fix an encoding preamble so the tests pass on 2.7.9. It seems there was a change in the parsing of encoding preambles in Python 2.7.9, compared to previous 2.7.x Pythons. This is a backport of the only piece of e520a73eeea6b185b719901ab9985ecef00e5664 that's needed to prevent an import failure under 2.7.9. --- tests/regressiontests/utils/jslex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/regressiontests/utils/jslex.py b/tests/regressiontests/utils/jslex.py index 7cd93ca36d79..43ea2293ef6c 100644 --- a/tests/regressiontests/utils/jslex.py +++ b/tests/regressiontests/utils/jslex.py @@ -1,5 +1,5 @@ -"""Tests for jslex.""" # encoding: utf-8 +"""Tests for jslex.""" # originally from https://bitbucket.org/ned/jslex from django.test import TestCase From 3b20558beb6abef0b53f3c8e4ca6b598219f1d0d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 4 Mar 2015 07:49:12 -0500 Subject: [PATCH 351/367] [1.4.x] Added stub release notes for security releases. --- docs/releases/1.4.20.txt | 7 +++++++ docs/releases/index.txt | 1 + 2 files changed, 8 insertions(+) create mode 100644 docs/releases/1.4.20.txt diff --git a/docs/releases/1.4.20.txt b/docs/releases/1.4.20.txt new file mode 100644 index 000000000000..9c46c5510f47 --- /dev/null +++ b/docs/releases/1.4.20.txt @@ -0,0 +1,7 @@ +=========================== +Django 1.4.20 release notes +=========================== + +*March 18, 2015* + +Django 1.4.20 fixes one security issue in 1.4.19. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 58b32f05c655..99324a93828f 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.20 1.4.19 1.4.18 1.4.17 From 2342693b31f740a422abf7267c53b4e7bc487c1b Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 9 Mar 2015 20:05:13 -0400 Subject: [PATCH 352/367] [1.4.x] Made is_safe_url() reject URLs that start with control characters. This is a security fix; disclosure to follow shortly. --- django/utils/http.py | 9 ++++++++- docs/releases/1.4.20.txt | 19 +++++++++++++++++++ tests/regressiontests/utils/http.py | 4 +++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/django/utils/http.py b/django/utils/http.py index e69a92b57883..b8c81a8418d7 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -4,6 +4,7 @@ import sys import urllib import urlparse +import unicodedata from email.utils import formatdate from django.utils.datastructures import MultiValueDict @@ -232,9 +233,10 @@ def is_safe_url(url, host=None): Always returns ``False`` on an empty url. """ + if url is not None: + url = url.strip() if not url: return False - url = url.strip() # Chrome treats \ completely as / url = url.replace('\\', '/') # Chrome considers any URL with more than two slashes to be absolute, but @@ -248,5 +250,10 @@ def is_safe_url(url, host=None): # allow this syntax. if not url_info[1] and url_info[0]: return False + # Forbid URLs that start with control characters. Some browsers (like + # Chrome) ignore quite a few control characters at the start of a + # URL and might consider the URL as scheme relative. + if unicodedata.category(unicode(url[0]))[0] == 'C': + return False return (not url_info[1] or url_info[1] == host) and \ (not url_info[0] or url_info[0] in ['http', 'https']) diff --git a/docs/releases/1.4.20.txt b/docs/releases/1.4.20.txt index 9c46c5510f47..f2ca5ac103f8 100644 --- a/docs/releases/1.4.20.txt +++ b/docs/releases/1.4.20.txt @@ -5,3 +5,22 @@ Django 1.4.20 release notes *March 18, 2015* Django 1.4.20 fixes one security issue in 1.4.19. + +Mitigated possible XSS attack via user-supplied redirect URLs +============================================================= + +Django relies on user input in some cases (e.g. +:func:`django.contrib.auth.views.login` and :doc:`i18n `) +to redirect the user to an "on success" URL. The security checks for these +redirects (namely ``django.utils.http.is_safe_url()``) accepted URLs with +leading control characters and so considered URLs like ``\x08javascript:...`` +safe. This issue doesn't affect Django currently, since we only put this URL +into the ``Location`` response header and browsers seem to ignore JavaScript +there. Browsers we tested also treat URLs prefixed with control characters such +as ``%08//example.com`` as relative paths so redirection to an unsafe target +isn't a problem either. + +However, if a developer relies on ``is_safe_url()`` to +provide safe redirect targets and puts such a URL into a link, they could +suffer from an XSS attack as some browsers such as Google Chrome ignore control +characters at the start of a URL in an anchor ``href``. diff --git a/tests/regressiontests/utils/http.py b/tests/regressiontests/utils/http.py index 3ec237ae6440..8245a7e54e6b 100644 --- a/tests/regressiontests/utils/http.py +++ b/tests/regressiontests/utils/http.py @@ -98,7 +98,9 @@ def test_is_safe_url(self): 'http:\/example.com', 'http:/\example.com', 'javascript:alert("XSS")' - '\njavascript:alert(x)'): + '\njavascript:alert(x)', + '\x08//example.com', + '\n'): self.assertFalse(http.is_safe_url(bad_url, host='testserver'), "%s should be blocked" % bad_url) for good_url in ('/view/?param=http://example.com', '/view/?param=https://example.com', From 5388692144973ca17ea09612c92dd0b75207f642 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 18 Mar 2015 08:43:42 -0400 Subject: [PATCH 353/367] [1.4.x] Bumped version for 1.4.20 release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index c3880969b334..5db5a04aa34b 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 20, 'alpha', 0) +VERSION = (1, 4, 20, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index bf2f41af918e..5c4435ad08b0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.19' +version = '1.4.20' # The full version, including alpha/beta/rc tags. -release = '1.4.19' +release = '1.4.20' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index a7e7b05bc85d..77eabfaef638 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.19.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.20.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From b2a7878c109e619b6eeefaabb43f815f508b7f6e Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 18 Mar 2015 20:22:09 -0400 Subject: [PATCH 354/367] [1.4.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 5db5a04aa34b..12f92d6b0b3a 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 20, 'final', 0) +VERSION = (1, 4, 21, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 664ad1252cfba8510b6cd173562de632b98b06d9 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Sat, 4 Apr 2015 07:59:16 -0400 Subject: [PATCH 355/367] [1.4.x] Added link to download page to find supported versions. Backport of 8c4827ec1d44fee05db189766174c115795a495e from master --- docs/internals/release-process.txt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/internals/release-process.txt b/docs/internals/release-process.txt index ea223a4b8644..ccec5a36830f 100644 --- a/docs/internals/release-process.txt +++ b/docs/internals/release-process.txt @@ -88,7 +88,8 @@ Supported versions ================== At any moment in time, Django's developer team will support a set of releases to -varying levels: +varying levels. See `the download page`_ for the current state of support for +each version. * The current development trunk will get new features and bug fixes requiring major refactoring. @@ -143,13 +144,10 @@ to be "Long-term support" (LTS) releases. LTS releases will get security fixes applied for a guaranteed period of time, typically 3+ years, regardless of the pace of releases afterwards. -The follow releases have been designated for long-term support: +See `the download page`_ for the releases that have been designated for +long-term support. -* Django 1.8, supported for at least 3 years after its release (scheduled for - April 2015). -* Django 1.4, supported for 6 months after the release of Django 1.8. As - Django 1.8 is scheduled to be released around April 2015, support for 1.4 - will end around October 2015. +.. _the download page: https://www.djangoproject.com/download/ .. _release-process: From 91a395fa807dda9a59ce9bfd17da90767b09e249 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 1 Jul 2015 10:41:23 -0400 Subject: [PATCH 356/367] [1.4.x] Backported .gitignore and .hgignore from master. --- .gitignore | 7 +++++++ .hgignore | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 105829ac74b9..504361b225ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ *.egg-info *.pot *.py[co] +__pycache__ +MANIFEST +dist/ docs/_build/ +docs/locale/ +node_modules/ tests/coverage_html/ tests/.coverage +build/ +tests/report/ diff --git a/.hgignore b/.hgignore index 3dc253a3c10c..8c900d573a91 100644 --- a/.hgignore +++ b/.hgignore @@ -3,6 +3,13 @@ syntax:glob *.egg-info *.pot *.py[co] +__pycache__ +MANIFEST +dist/ docs/_build/ +docs/locale/ +node_modules/ tests/coverage_html/ -tests/.coverage \ No newline at end of file +tests/.coverage +build/ +tests/report/ From c570a5ec3ef673eaad18dccb70bcda9f762e4354 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 15 Jun 2015 18:29:46 -0400 Subject: [PATCH 357/367] [1.4.x] Added security release note stubs. --- docs/releases/1.4.21.txt | 7 +++++++ docs/releases/index.txt | 1 + 2 files changed, 8 insertions(+) create mode 100644 docs/releases/1.4.21.txt diff --git a/docs/releases/1.4.21.txt b/docs/releases/1.4.21.txt new file mode 100644 index 000000000000..6ff4c6d11508 --- /dev/null +++ b/docs/releases/1.4.21.txt @@ -0,0 +1,7 @@ +=========================== +Django 1.4.21 release notes +=========================== + +*July 8, 2015* + +Django 1.4.21 fixes several security issues in 1.4.20. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 99324a93828f..095e952e80ae 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.21 1.4.20 1.4.19 1.4.18 From 2e47f3e401c29bc2ba5ab794d483cb0820855fb9 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Wed, 10 Jun 2015 15:45:20 -0600 Subject: [PATCH 358/367] [1.4.x] Fixed #19324 -- Avoided creating a session record when loading the session. The session record is now only created if/when the session is modified. This prevents a potential DoS via creation of many empty session records. This is a security fix; disclosure to follow shortly. --- django/contrib/sessions/backends/cache.py | 6 ++++-- django/contrib/sessions/backends/cached_db.py | 5 +++-- django/contrib/sessions/backends/db.py | 5 +++-- django/contrib/sessions/backends/file.py | 7 ++++--- django/contrib/sessions/tests.py | 19 +++++++++++++++++ docs/releases/1.4.21.txt | 21 +++++++++++++++++++ 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/django/contrib/sessions/backends/cache.py b/django/contrib/sessions/backends/cache.py index 467d5f126561..ffea036c5eac 100644 --- a/django/contrib/sessions/backends/cache.py +++ b/django/contrib/sessions/backends/cache.py @@ -25,7 +25,7 @@ def load(self): session_data = None if session_data is not None: return session_data - self.create() + self._session_key = None return {} def create(self): @@ -45,6 +45,8 @@ def create(self): raise RuntimeError("Unable to create a new session key.") def save(self, must_create=False): + if self.session_key is None: + return self.create() if must_create: func = self._cache.add else: @@ -56,7 +58,7 @@ def save(self, must_create=False): raise CreateError def exists(self, session_key): - return (KEY_PREFIX + session_key) in self._cache + return session_key and (KEY_PREFIX + session_key) in self._cache def delete(self, session_key=None): if session_key is None: diff --git a/django/contrib/sessions/backends/cached_db.py b/django/contrib/sessions/backends/cached_db.py index ff6076df7763..e15f88358c78 100644 --- a/django/contrib/sessions/backends/cached_db.py +++ b/django/contrib/sessions/backends/cached_db.py @@ -30,11 +30,12 @@ def load(self): data = None if data is None: data = super(SessionStore, self).load() - cache.set(self.cache_key, data, settings.SESSION_COOKIE_AGE) + if self.session_key: + cache.set(self.cache_key, data, settings.SESSION_COOKIE_AGE) return data def exists(self, session_key): - if (KEY_PREFIX + session_key) in cache: + if session_key and (KEY_PREFIX + session_key) in cache: return True return super(SessionStore, self).exists(session_key) diff --git a/django/contrib/sessions/backends/db.py b/django/contrib/sessions/backends/db.py index 3dd0d9516c77..ad92baee921f 100644 --- a/django/contrib/sessions/backends/db.py +++ b/django/contrib/sessions/backends/db.py @@ -20,7 +20,7 @@ def load(self): ) return self.decode(force_unicode(s.session_data)) except (Session.DoesNotExist, SuspiciousOperation): - self.create() + self._session_key = None return {} def exists(self, session_key): @@ -37,7 +37,6 @@ def create(self): # Key wasn't unique. Try again. continue self.modified = True - self._session_cache = {} return def save(self, must_create=False): @@ -47,6 +46,8 @@ def save(self, must_create=False): create a *new* entry (as opposed to possibly updating an existing entry). """ + if self.session_key is None: + return self.create() obj = Session( session_key=self._get_or_create_session_key(), session_data=self.encode(self._get_session(no_load=must_create)), diff --git a/django/contrib/sessions/backends/file.py b/django/contrib/sessions/backends/file.py index 8ffddc4903af..05dc194f771c 100644 --- a/django/contrib/sessions/backends/file.py +++ b/django/contrib/sessions/backends/file.py @@ -56,11 +56,11 @@ def load(self): try: session_data = self.decode(file_data) except (EOFError, SuspiciousOperation): - self.create() + self._session_key = None finally: session_file.close() except IOError: - self.create() + self._session_key = None return session_data def create(self): @@ -71,10 +71,11 @@ def create(self): except CreateError: continue self.modified = True - self._session_cache = {} return def save(self, must_create=False): + if self.session_key is None: + return self.create() # Get the session data now, before we start messing # with the file it is stored within. session_data = self._get_session(no_load=must_create) diff --git a/django/contrib/sessions/tests.py b/django/contrib/sessions/tests.py index 7686bd254eeb..98271f4b0e54 100644 --- a/django/contrib/sessions/tests.py +++ b/django/contrib/sessions/tests.py @@ -162,6 +162,11 @@ def test_cycle(self): self.assertNotEqual(self.session.session_key, prev_key) self.assertEqual(self.session.items(), prev_data) + def test_save_doesnt_clear_data(self): + self.session['a'] = 'b' + self.session.save() + self.assertEqual(self.session['a'], 'b') + def test_invalid_key(self): # Submitting an invalid session key (either by guessing, or if the db has # removed the key) results in a new key being generated. @@ -256,6 +261,20 @@ def test_decode(self): encoded = self.session.encode(data) self.assertEqual(self.session.decode(encoded), data) + def test_session_load_does_not_create_record(self): + """ + Loading an unknown session key does not create a session record. + + Creating session records on load is a DOS vulnerability. + """ + if self.backend is CookieSession: + raise unittest.SkipTest("Cookie backend doesn't have an external store to create records in.") + session = self.backend('deadbeef') + session.load() + + self.assertFalse(session.exists(session.session_key)) + # provided unknown key was cycled, not reused + self.assertNotEqual(session.session_key, 'deadbeef') class DatabaseSessionTests(SessionTestsMixin, TestCase): diff --git a/docs/releases/1.4.21.txt b/docs/releases/1.4.21.txt index 6ff4c6d11508..da69b26564ee 100644 --- a/docs/releases/1.4.21.txt +++ b/docs/releases/1.4.21.txt @@ -5,3 +5,24 @@ Django 1.4.21 release notes *July 8, 2015* Django 1.4.21 fixes several security issues in 1.4.20. + +Denial-of-service possibility by filling session store +====================================================== + +In previous versions of Django, the session backends created a new empty record +in the session storage anytime ``request.session`` was accessed and there was a +session key provided in the request cookies that didn't already have a session +record. This could allow an attacker to easily create many new session records +simply by sending repeated requests with unknown session keys, potentially +filling up the session store or causing other users' session records to be +evicted. + +The built-in session backends now create a session record only if the session +is actually modified; empty session records are not created. Thus this +potential DoS is now only possible if the site chooses to expose a +session-modifying view to anonymous users. + +As each built-in session backend was fixed separately (rather than a fix in the +core sessions framework), maintainers of third-party session backends should +check whether the same vulnerability is present in their backend and correct +it if so. From 1ba1cdce7d58e6740fe51955d945b56ae51d072a Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 12 Jun 2015 13:49:31 -0400 Subject: [PATCH 359/367] [1.4.x] Prevented newlines from being accepted in some validators. This is a security fix; disclosure to follow shortly. Thanks to Sjoerd Job Postmus for the report and draft patch. --- django/core/validators.py | 26 +++++++++++++++----------- docs/releases/1.4.21.txt | 26 ++++++++++++++++++++++++++ tests/modeltests/validators/tests.py | 16 +++++++++++++++- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/django/core/validators.py b/django/core/validators.py index 95224e9de9ca..85bc7e30a04a 100644 --- a/django/core/validators.py +++ b/django/core/validators.py @@ -50,7 +50,7 @@ class URLValidator(RegexValidator): r'localhost|' #localhost... r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip r'(?::\d+)?' # optional port - r'(?:/?|[/?]\S+)$', re.IGNORECASE) + r'(?:/?|[/?]\S+)\Z', re.IGNORECASE) def __init__(self, verify_exists=False, validator_user_agent=URL_VALIDATOR_USER_AGENT): @@ -133,11 +133,16 @@ def __call__(self, value): raise broken_error +integer_validator = RegexValidator( + re.compile('^-?\d+\Z'), + message=_('Enter a valid integer.'), + code='invalid', +) + + def validate_integer(value): - try: - int(value) - except (ValueError, TypeError): - raise ValidationError('') + return integer_validator(value) + class EmailValidator(RegexValidator): @@ -160,14 +165,14 @@ def __call__(self, value): r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom # quoted-string, see also http://tools.ietf.org/html/rfc2822#section-3.2.5 r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*"' - r')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?$)' # domain - r'|\[(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\]$', re.IGNORECASE) # literal form, ipv4 address (SMTP 4.1.3) + r')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?\Z)' # domain + r'|\[(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\]\Z', re.IGNORECASE) # literal form, ipv4 address (SMTP 4.1.3) validate_email = EmailValidator(email_re, _(u'Enter a valid e-mail address.'), 'invalid') -slug_re = re.compile(r'^[-\w]+$') +slug_re = re.compile(r'^[-\w]+\Z') validate_slug = RegexValidator(slug_re, _(u"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."), 'invalid') -ipv4_re = re.compile(r'^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}$') +ipv4_re = re.compile(r'^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z') validate_ipv4_address = RegexValidator(ipv4_re, _(u'Enter a valid IPv4 address.'), 'invalid') def validate_ipv6_address(value): @@ -205,7 +210,7 @@ def ip_address_validators(protocol, unpack_ipv4): raise ValueError("The protocol '%s' is unknown. Supported: %s" % (protocol, ip_address_validator_map.keys())) -comma_separated_int_list_re = re.compile('^[\d,]+$') +comma_separated_int_list_re = re.compile('^[\d,]+\Z') validate_comma_separated_integer_list = RegexValidator(comma_separated_int_list_re, _(u'Enter only digits separated by commas.'), 'invalid') @@ -249,4 +254,3 @@ class MaxLengthValidator(BaseValidator): clean = lambda self, x: len(x) message = _(u'Ensure this value has at most %(limit_value)d characters (it has %(show_value)d).') code = 'max_length' - diff --git a/docs/releases/1.4.21.txt b/docs/releases/1.4.21.txt index da69b26564ee..477f9a722cae 100644 --- a/docs/releases/1.4.21.txt +++ b/docs/releases/1.4.21.txt @@ -26,3 +26,29 @@ As each built-in session backend was fixed separately (rather than a fix in the core sessions framework), maintainers of third-party session backends should check whether the same vulnerability is present in their backend and correct it if so. + +Header injection possibility since validators accept newlines in input +====================================================================== + +Some of Django's built-in validators +(``django.core.validators.EmailValidator``, most seriously) didn't +prohibit newline characters (due to the usage of ``$`` instead of ``\Z`` in the +regular expressions). If you use values with newlines in HTTP response or email +headers, you can suffer from header injection attacks. Django itself isn't +vulnerable because :class:`~django.http.HttpResponse` and the mail sending +utilities in :mod:`django.core.mail` prohibit newlines in HTTP and SMTP +headers, respectively. While the validators have been fixed in Django, if +you're creating HTTP responses or email messages in other ways, it's a good +idea to ensure that those methods prohibit newlines as well. You might also +want to validate that any existing data in your application doesn't contain +unexpected newlines. + +:func:`~django.core.validators.validate_ipv4_address`, +:func:`~django.core.validators.validate_slug`, and +:class:`~django.core.validators.URLValidator` and their usage in the +corresponding form fields ``GenericIPAddresseField``, ``IPAddressField``, +``SlugField``, and ``URLField`` are also affected. + +The undocumented, internally unused ``validate_integer()`` function is now +stricter as it validates using a regular expression instead of simply casting +the value using ``int()`` and checking if an exception was raised. diff --git a/tests/modeltests/validators/tests.py b/tests/modeltests/validators/tests.py index a1a48bf97cc9..01d2bf2a6b34 100644 --- a/tests/modeltests/validators/tests.py +++ b/tests/modeltests/validators/tests.py @@ -11,14 +11,17 @@ NOW = datetime.now() TEST_DATA = ( + # (validator, value, expected), # (validator, value, expected), (validate_integer, '42', None), (validate_integer, '-42', None), (validate_integer, -42, None), - (validate_integer, -42.5, None), + (validate_integer, -42.5, ValidationError), (validate_integer, None, ValidationError), (validate_integer, 'a', ValidationError), + (validate_integer, '\n42', ValidationError), + (validate_integer, '42\n', ValidationError), (validate_email, 'email@here.com', None), (validate_email, 'weirder-email@here.and.there.com', None), @@ -33,6 +36,11 @@ # Quoted-string format (CR not allowed) (validate_email, '"\\\011"@here.com', None), (validate_email, '"\\\012"@here.com', ValidationError), + # Trailing newlines in username or domain not allowed + (validate_email, 'a@b.com\n', ValidationError), + (validate_email, 'a\n@b.com', ValidationError), + (validate_email, '"test@test"\n@example.com', ValidationError), + (validate_email, 'a@[127.0.0.1]\n', ValidationError), (validate_slug, 'slug-ok', None), (validate_slug, 'longer-slug-still-ok', None), @@ -45,6 +53,7 @@ (validate_slug, 'some@mail.com', ValidationError), (validate_slug, '你好', ValidationError), (validate_slug, '\n', ValidationError), + (validate_slug, 'trailing-newline\n', ValidationError), (validate_ipv4_address, '1.1.1.1', None), (validate_ipv4_address, '255.0.0.0', None), @@ -54,6 +63,7 @@ (validate_ipv4_address, '25.1.1.', ValidationError), (validate_ipv4_address, '25,1,1,1', ValidationError), (validate_ipv4_address, '25.1 .1.1', ValidationError), + (validate_ipv4_address, '1.1.1.1\n', ValidationError), # validate_ipv6_address uses django.utils.ipv6, which # is tested in much greater detail in it's own testcase @@ -87,6 +97,7 @@ (validate_comma_separated_integer_list, '', ValidationError), (validate_comma_separated_integer_list, 'a,b,c', ValidationError), (validate_comma_separated_integer_list, '1, 2, 3', ValidationError), + (validate_comma_separated_integer_list, '1,2,3\n', ValidationError), (MaxValueValidator(10), 10, None), (MaxValueValidator(10), -10, None), @@ -138,6 +149,9 @@ (URLValidator(), 'http://-invalid.com', ValidationError), (URLValidator(), 'http://inv-.alid-.com', ValidationError), (URLValidator(), 'http://inv-.-alid.com', ValidationError), + # Trailing newlines not accepted + (URLValidator(), 'http://www.djangoproject.com/\n', ValidationError), + (URLValidator(), 'http://[::ffff:192.9.5.5]\n', ValidationError), (BaseValidator(True), True, None), (BaseValidator(True), False, ValidationError), From 622a11513ea04b779d6dd948293839dcd543084d Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 8 Jul 2015 07:39:43 -0400 Subject: [PATCH 360/367] [1.4.x] Bumped version for 1.4.21 release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 12f92d6b0b3a..9c7214fed944 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 21, 'alpha', 0) +VERSION = (1, 4, 21, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 5c4435ad08b0..4b47388387a1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.20' +version = '1.4.21' # The full version, including alpha/beta/rc tags. -release = '1.4.20' +release = '1.4.21' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index 77eabfaef638..f3f6f58eda11 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.20.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.21.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 3df6495c12b35adef9d8e77e33adf59a212c06bb Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 8 Jul 2015 16:01:55 -0400 Subject: [PATCH 361/367] [1.4.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 9c7214fed944..236615037842 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 21, 'final', 0) +VERSION = (1, 4, 22, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 3b324970e390a6dc4c373db036d6f27300d7fded Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Mon, 13 Jul 2015 16:03:51 -0400 Subject: [PATCH 362/367] [1.4.x] Fixed #25119 -- Disabled wheel support. --- docs/releases/1.4.22.txt | 9 +++++++++ docs/releases/index.txt | 1 + setup.py | 3 +++ 3 files changed, 13 insertions(+) create mode 100644 docs/releases/1.4.22.txt diff --git a/docs/releases/1.4.22.txt b/docs/releases/1.4.22.txt new file mode 100644 index 000000000000..3abbe5c60b00 --- /dev/null +++ b/docs/releases/1.4.22.txt @@ -0,0 +1,9 @@ +=========================== +Django 1.4.22 release notes +=========================== + +*Under development* + +Django 1.4.22 fixes support with pip 7+ by disabling wheel support. Older +versions of 1.4 would silently build a broken wheel when installed with those +versions of pip. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 095e952e80ae..3c2e6b1707fb 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -19,6 +19,7 @@ Final releases .. toctree:: :maxdepth: 1 + 1.4.22 1.4.21 1.4.20 1.4.19 diff --git a/setup.py b/setup.py index f3f6f58eda11..fd824d5e8917 100644 --- a/setup.py +++ b/setup.py @@ -65,6 +65,9 @@ def fullsplit(path, result=None): for file_info in data_files: file_info[0] = '\\PURELIB\\%s' % file_info[0] +if 'bdist_wheel' in sys.argv: + raise RuntimeError('Django 1.4 does not support wheel. This error is safe to ignore.') + # Dynamically calculate the version based on django.VERSION. version = __import__('django').get_version() From 8b0d63914fe2af6a02ed3105e61d9ff8ee6f45c4 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 7 Aug 2015 18:26:40 -0400 Subject: [PATCH 363/367] [1.4.x] Added stub release notes for security releases. --- docs/releases/1.4.22.txt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/releases/1.4.22.txt b/docs/releases/1.4.22.txt index 3abbe5c60b00..d8ce24bc6808 100644 --- a/docs/releases/1.4.22.txt +++ b/docs/releases/1.4.22.txt @@ -2,8 +2,10 @@ Django 1.4.22 release notes =========================== -*Under development* +*August 18, 2015* -Django 1.4.22 fixes support with pip 7+ by disabling wheel support. Older -versions of 1.4 would silently build a broken wheel when installed with those -versions of pip. +Django 1.4.22 fixes a security issue in 1.4.21. + +It also fixes support with pip 7+ by disabling wheel support. Older versions +of 1.4 would silently build a broken wheel when installed with those versions +of pip. From 575f59f9bc7c59a5e41a081d1f5f55fc859c5012 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 5 Aug 2015 17:44:48 -0400 Subject: [PATCH 364/367] [1.4.x] Fixed DoS possiblity in contrib.auth.views.logout() Refs #20936 -- When logging out/ending a session, don't create a new, empty session. Previously, when logging out, the existing session was overwritten by a new sessionid instead of deleting the session altogether. This behavior added overhead by creating a new session record in whichever backend was in use: db, cache, etc. This extra session is unnecessary at the time since no session data is meant to be preserved when explicitly logging out. Backport of 393c0e24223c701edeb8ce7dc9d0f852f0c081ad, 088579638b160f3716dc81d194be70c72743593f, and 2dee853ed4def42b7ef1b3b472b395055543cc00 from master Thanks Florian Apolloner and Carl Meyer for review. This is a security fix. --- django/contrib/sessions/backends/base.py | 9 ++- django/contrib/sessions/backends/cached_db.py | 2 +- django/contrib/sessions/middleware.py | 46 +++++++----- django/contrib/sessions/tests.py | 70 +++++++++++++++++++ docs/releases/1.4.22.txt | 18 +++++ docs/topics/http/sessions.txt | 13 ++-- 6 files changed, 133 insertions(+), 25 deletions(-) diff --git a/django/contrib/sessions/backends/base.py b/django/contrib/sessions/backends/base.py index 5a637e24d23f..08e65e965bbe 100644 --- a/django/contrib/sessions/backends/base.py +++ b/django/contrib/sessions/backends/base.py @@ -128,6 +128,13 @@ def clear(self): self.accessed = True self.modified = True + def is_empty(self): + "Returns True when there is no session_key and the session is empty" + try: + return not bool(self._session_key) and not self._session_cache + except AttributeError: + return True + def _get_new_session_key(self): "Returns session key that isn't being used." # Todo: move to 0-9a-z charset in 1.5 @@ -230,7 +237,7 @@ def flush(self): """ self.clear() self.delete() - self.create() + self._session_key = None def cycle_key(self): """ diff --git a/django/contrib/sessions/backends/cached_db.py b/django/contrib/sessions/backends/cached_db.py index e15f88358c78..b145fd145ac8 100644 --- a/django/contrib/sessions/backends/cached_db.py +++ b/django/contrib/sessions/backends/cached_db.py @@ -58,4 +58,4 @@ def flush(self): """ self.clear() self.delete(self.session_key) - self.create() + self._session_key = None diff --git a/django/contrib/sessions/middleware.py b/django/contrib/sessions/middleware.py index 68cb77f7e1a1..256a845d7bca 100644 --- a/django/contrib/sessions/middleware.py +++ b/django/contrib/sessions/middleware.py @@ -14,30 +14,38 @@ def process_request(self, request): def process_response(self, request, response): """ If request.session was modified, or if the configuration is to save the - session every time, save the changes and set a session cookie. + session every time, save the changes and set a session cookie or delete + the session cookie if the session has been emptied. """ try: accessed = request.session.accessed modified = request.session.modified + empty = request.session.is_empty() except AttributeError: pass else: - if accessed: - patch_vary_headers(response, ('Cookie',)) - if modified or settings.SESSION_SAVE_EVERY_REQUEST: - if request.session.get_expire_at_browser_close(): - max_age = None - expires = None - else: - max_age = request.session.get_expiry_age() - expires_time = time.time() + max_age - expires = cookie_date(expires_time) - # Save the session data and refresh the client cookie. - request.session.save() - response.set_cookie(settings.SESSION_COOKIE_NAME, - 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, - httponly=settings.SESSION_COOKIE_HTTPONLY or None) + # First check if we need to delete this cookie. + # The session should be deleted only if the session is entirely empty + if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: + response.delete_cookie(settings.SESSION_COOKIE_NAME, + domain=settings.SESSION_COOKIE_DOMAIN) + else: + if accessed: + patch_vary_headers(response, ('Cookie',)) + if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: + if request.session.get_expire_at_browser_close(): + max_age = None + expires = None + else: + max_age = request.session.get_expiry_age() + expires_time = time.time() + max_age + expires = cookie_date(expires_time) + # Save the session data and refresh the client cookie. + request.session.save() + response.set_cookie(settings.SESSION_COOKIE_NAME, + 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, + httponly=settings.SESSION_COOKIE_HTTPONLY or None) return response diff --git a/django/contrib/sessions/tests.py b/django/contrib/sessions/tests.py index 98271f4b0e54..2b269437aa88 100644 --- a/django/contrib/sessions/tests.py +++ b/django/contrib/sessions/tests.py @@ -150,6 +150,7 @@ def test_flush(self): self.session.flush() self.assertFalse(self.session.exists(prev_key)) self.assertNotEqual(self.session.session_key, prev_key) + self.assertIsNone(self.session.session_key) self.assertTrue(self.session.modified) self.assertTrue(self.session.accessed) @@ -432,6 +433,75 @@ def test_no_httponly_session_cookie(self): self.assertNotIn('httponly', str(response.cookies[settings.SESSION_COOKIE_NAME])) + def test_session_delete_on_end(self): + request = RequestFactory().get('/') + response = HttpResponse('Session test') + middleware = SessionMiddleware() + + # Before deleting, there has to be an existing cookie + request.COOKIES[settings.SESSION_COOKIE_NAME] = 'abc' + + # Simulate a request that ends the session + middleware.process_request(request) + request.session.flush() + + # Handle the response through the middleware + response = middleware.process_response(request, response) + + # Check that the cookie was deleted, not recreated. + # A deleted cookie header looks like: + # Set-Cookie: sessionid=; expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/ + self.assertEqual( + 'Set-Cookie: %s=; expires=Thu, 01-Jan-1970 00:00:00 GMT; ' + 'Max-Age=0; Path=/' % settings.SESSION_COOKIE_NAME, + str(response.cookies[settings.SESSION_COOKIE_NAME]) + ) + + @override_settings(SESSION_COOKIE_DOMAIN='.example.local') + def test_session_delete_on_end_with_custom_domain(self): + request = RequestFactory().get('/') + response = HttpResponse('Session test') + middleware = SessionMiddleware() + + # Before deleting, there has to be an existing cookie + request.COOKIES[settings.SESSION_COOKIE_NAME] = 'abc' + + # Simulate a request that ends the session + middleware.process_request(request) + request.session.flush() + + # Handle the response through the middleware + response = middleware.process_response(request, response) + + # Check that the cookie was deleted, not recreated. + # A deleted cookie header with a custom domain looks like: + # Set-Cookie: sessionid=; Domain=.example.local; + # expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/ + self.assertEqual( + 'Set-Cookie: %s=; Domain=.example.local; expires=Thu, ' + '01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/' % ( + settings.SESSION_COOKIE_NAME, + ), + str(response.cookies[settings.SESSION_COOKIE_NAME]) + ) + + def test_flush_empty_without_session_cookie_doesnt_set_cookie(self): + request = RequestFactory().get('/') + response = HttpResponse('Session test') + middleware = SessionMiddleware() + + # Simulate a request that ends the session + middleware.process_request(request) + request.session.flush() + + # Handle the response through the middleware + response = middleware.process_response(request, response) + + # A cookie should not be set. + self.assertEqual(response.cookies, {}) + # The session is accessed so "Vary: Cookie" should be set. + self.assertEqual(response['Vary'], 'Cookie') + class CookieSessionTests(SessionTestsMixin, TestCase): diff --git a/docs/releases/1.4.22.txt b/docs/releases/1.4.22.txt index d8ce24bc6808..9f8177440fca 100644 --- a/docs/releases/1.4.22.txt +++ b/docs/releases/1.4.22.txt @@ -9,3 +9,21 @@ Django 1.4.22 fixes a security issue in 1.4.21. It also fixes support with pip 7+ by disabling wheel support. Older versions of 1.4 would silently build a broken wheel when installed with those versions of pip. + +Denial-of-service possibility in ``logout()`` view by filling session store +=========================================================================== + +Previously, a session could be created when anonymously accessing the +:func:`django.contrib.auth.views.logout` view (provided it wasn't decorated +with :func:`~django.contrib.auth.decorators.login_required` as done in the +admin). This could allow an attacker to easily create many new session records +by sending repeated requests, potentially filling up the session store or +causing other users' session records to be evicted. + +The :class:`~django.contrib.sessions.middleware.SessionMiddleware` has been +modified to no longer create empty session records. + +Additionally, the ``contrib.sessions.backends.base.SessionBase.flush()`` and +``cache_db.SessionStore.flush()`` methods have been modified to avoid creating +a new empty session. Maintainers of third-party session backends should check +if the same vulnerability is present in their backend and correct it if so. diff --git a/docs/topics/http/sessions.txt b/docs/topics/http/sessions.txt index bb9d73af97d0..2849582cd3cf 100644 --- a/docs/topics/http/sessions.txt +++ b/docs/topics/http/sessions.txt @@ -197,12 +197,17 @@ You can edit it multiple times. .. method:: flush - Delete the current session data from the session and regenerate the - session key value that is sent back to the user in the cookie. This is - used if you want to ensure that the previous session data can't be - accessed again from the user's browser (for example, the + Deletes the current session data from the session and deletes the session + cookie. This is used if you want to ensure that the previous session data + can't be accessed again from the user's browser (for example, the :func:`django.contrib.auth.logout()` function calls it). + .. versionchanged:: 1.4.22 + + Deletion of the session cookie was added. Previously, the behavior + was to regenerate the session key value that was sent back to the + user in the cookie, but this was a denial-of-service vulnerability. + .. method:: set_test_cookie Sets a test cookie to determine whether the user's browser supports From 9ff23eb7cc48d9044116772618b8d91ffcb10d3f Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 18 Aug 2015 08:39:59 -0400 Subject: [PATCH 365/367] [1.4.x] Bumped version for 1.4.22 release. --- django/__init__.py | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/django/__init__.py b/django/__init__.py index 236615037842..7161c80ca87c 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 22, 'alpha', 0) +VERSION = (1, 4, 22, 'final', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" diff --git a/docs/conf.py b/docs/conf.py index 4b47388387a1..d3679fe3b583 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '1.4.21' +version = '1.4.22' # The full version, including alpha/beta/rc tags. -release = '1.4.21' +release = '1.4.22' # The next version to be released django_next_version = '1.5' diff --git a/setup.py b/setup.py index fd824d5e8917..bd7a27e7b1dd 100644 --- a/setup.py +++ b/setup.py @@ -78,7 +78,7 @@ def fullsplit(path, result=None): author = 'Django Software Foundation', author_email = 'foundation@djangoproject.com', description = 'A high-level Python Web framework that encourages rapid development and clean, pragmatic design.', - download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.21.tar.gz', + download_url = 'https://www.djangoproject.com/m/releases/1.4/Django-1.4.22.tar.gz', packages = packages, cmdclass = cmdclasses, data_files = data_files, From 018efef59a9e2aad940f302738fc71301044adee Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 18 Aug 2015 13:32:24 -0400 Subject: [PATCH 366/367] [1.4.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 7161c80ca87c..f08b4abaf55d 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 4, 22, 'final', 0) +VERSION = (1, 4, 23, 'alpha', 0) def get_version(version=None): """Derives a PEP386-compliant version number from VERSION.""" From 5864e1f8e91d31d914c27885240ea6e065b38fd4 Mon Sep 17 00:00:00 2001 From: Ramon Moraes Date: Mon, 1 Feb 2016 22:58:41 -0300 Subject: [PATCH 367/367] [1.4.x] Updated xhtml2pdf URL in docs. --- docs/howto/outputting-pdf.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/howto/outputting-pdf.txt b/docs/howto/outputting-pdf.txt index 799cd24ae4af..7ed9320782a7 100644 --- a/docs/howto/outputting-pdf.txt +++ b/docs/howto/outputting-pdf.txt @@ -152,8 +152,8 @@ Further resources using ``system`` or ``popen`` and retrieve the output in Python. .. _PDFlib: http://www.pdflib.org/ -.. _`Pisa XHTML2PDF`: http://www.xhtml2pdf.com/ -.. _HTMLdoc: http://www.htmldoc.org/ +.. _`Pisa XHTML2PDF`: https://github.com/xhtml2pdf/xhtml2pdf +.. _HTMLdoc: https://www.msweet.org/projects.php?Z1 Other formats =============