diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py index 0c8ac1c6fc..3c3ef3bbd9 100644 --- a/django/contrib/admin/utils.py +++ b/django/contrib/admin/utils.py @@ -57,7 +57,7 @@ def quote(s): res = list(s) for i in range(len(res)): c = res[i] - if c in """:/_#?;@&=+$,"<>%\\""": + if c in """:/_#?;@&=+$,"[]<>%\\""": res[i] = '_%02X' % ord(c) return ''.join(res) diff --git a/django/core/urlresolvers.py b/django/core/urlresolvers.py index e8564ecc76..d3bfef423b 100644 --- a/django/core/urlresolvers.py +++ b/django/core/urlresolvers.py @@ -20,7 +20,7 @@ from django.utils.datastructures import MultiValueDict from django.utils.deprecation import RemovedInDjango20Warning from django.utils.encoding import force_str, force_text, iri_to_uri from django.utils.functional import lazy -from django.utils.http import urlquote +from django.utils.http import RFC3986_SUBDELIMS, urlquote from django.utils.module_loading import module_has_submodule from django.utils.regex_helper import normalize from django.utils import six, lru_cache @@ -453,7 +453,9 @@ class RegexURLResolver(LocaleRegexProvider): # arguments in order to return a properly encoded URL. candidate_pat = prefix_norm.replace('%', '%%') + result if re.search('^%s%s' % (prefix_norm, pattern), candidate_pat % candidate_subs, re.UNICODE): - candidate_subs = dict((k, urlquote(v)) for (k, v) in candidate_subs.items()) + # safe characters from `pchar` definition of RFC 3986 + candidate_subs = dict((k, urlquote(v, safe=RFC3986_SUBDELIMS + str('/~:@'))) + for (k, v) in candidate_subs.items()) return candidate_pat % candidate_subs # 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/django/utils/html.py b/django/utils/html.py index 38fe90d1a9..d5fe2f4a6b 100644 --- a/django/utils/html.py +++ b/django/utils/html.py @@ -7,6 +7,7 @@ import sys from django.utils.encoding import force_text, force_str from django.utils.functional import allow_lazy +from django.utils.http import RFC3986_GENDELIMS, RFC3986_SUBDELIMS from django.utils.safestring import SafeData, mark_safe from django.utils import six from django.utils.six.moves.urllib.parse import quote, unquote, urlsplit, urlunsplit @@ -215,7 +216,7 @@ def smart_urlquote(url): url = unquote(force_str(url)) # See http://bugs.python.org/issue2637 - url = quote(url, safe=b'!*\'();:@&=+$,/?#[]~') + url = quote(url, safe=RFC3986_SUBDELIMS + RFC3986_GENDELIMS + str('~')) return force_text(url) diff --git a/django/utils/http.py b/django/utils/http.py index 27d445d46f..2375f95b5b 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -30,6 +30,9 @@ RFC1123_DATE = re.compile(r'^\w{3}, %s %s %s %s GMT$' % (__D, __M, __Y, __T)) RFC850_DATE = re.compile(r'^\w{6,9}, %s-%s-%s %s GMT$' % (__D, __M, __Y2, __T)) ASCTIME_DATE = re.compile(r'^\w{3} %s %s %s %s$' % (__M, __D2, __T, __Y)) +RFC3986_GENDELIMS = str(":/?#[]@") +RFC3986_SUBDELIMS = str("!$&'()*+,;=") + def urlquote(url, safe='/'): """ diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index f3e060fd92..6006a0e504 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -36,7 +36,7 @@ from django.utils import translation from django.utils.cache import get_max_age from django.utils.encoding import iri_to_uri, force_bytes, force_text from django.utils.html import escape -from django.utils.http import urlencode, urlquote +from django.utils.http import urlencode from django.utils.six.moves.urllib.parse import parse_qsl, urljoin, urlparse from django.utils._os import upath from django.utils import six @@ -1748,7 +1748,7 @@ class AdminViewStringPrimaryKeyTest(TestCase): prefix = '/test_admin/admin/admin_views/modelwithstringprimarykey/' response = self.client.get(prefix) # this URL now comes through reverse(), thus url quoting and iri_to_uri encoding - pk_final_url = escape(iri_to_uri(urlquote(quote(self.pk)))) + pk_final_url = escape(iri_to_uri(quote(self.pk))) should_contain = """%s""" % (prefix, pk_final_url, escape(self.pk)) self.assertContains(response, should_contain) @@ -1756,14 +1756,14 @@ class AdminViewStringPrimaryKeyTest(TestCase): "The link from the recent actions list referring to the changeform of the object should be quoted" response = self.client.get('/test_admin/admin/') link = reverse('admin:admin_views_modelwithstringprimarykey_change', args=(quote(self.pk),)) - should_contain = """%s""" % (link, escape(self.pk)) + should_contain = """%s""" % (escape(link), escape(self.pk)) self.assertContains(response, should_contain) def test_recentactions_without_content_type(self): "If a LogEntry is missing content_type it will not display it in span tag under the hyperlink." response = self.client.get('/test_admin/admin/') link = reverse('admin:admin_views_modelwithstringprimarykey_change', args=(quote(self.pk),)) - should_contain = """%s""" % (link, escape(self.pk)) + should_contain = """%s""" % (escape(link), escape(self.pk)) self.assertContains(response, should_contain) should_contain = "Model with string primary key" # capitalized in Recent Actions self.assertContains(response, should_contain) @@ -1785,7 +1785,7 @@ class AdminViewStringPrimaryKeyTest(TestCase): log_entry_name = "Model with string primary key" # capitalized in Recent Actions logentry = LogEntry.objects.get(content_type__name__iexact=log_entry_name) model = "modelwithstringprimarykey" - desired_admin_url = "/test_admin/admin/admin_views/%s/%s/" % (model, escape(iri_to_uri(urlquote(quote(self.pk))))) + desired_admin_url = "/test_admin/admin/admin_views/%s/%s/" % (model, iri_to_uri(quote(self.pk))) self.assertEqual(logentry.get_admin_url(), desired_admin_url) logentry.content_type.model = "non-existent" @@ -1795,7 +1795,7 @@ class AdminViewStringPrimaryKeyTest(TestCase): "The link from the delete confirmation page referring back to the changeform of the object should be quoted" response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/%s/delete/' % quote(self.pk)) # this URL now comes through reverse(), thus url quoting and iri_to_uri encoding - should_contain = """/%s/">%s""" % (escape(iri_to_uri(urlquote(quote(self.pk)))), escape(self.pk)) + should_contain = """/%s/">%s""" % (escape(iri_to_uri(quote(self.pk))), escape(self.pk)) self.assertContains(response, should_contain) def test_url_conflicts_with_add(self): diff --git a/tests/template_tests/tests.py b/tests/template_tests/tests.py index b418cdac11..4ec6815710 100644 --- a/tests/template_tests/tests.py +++ b/tests/template_tests/tests.py @@ -1673,12 +1673,12 @@ class TemplateTests(TestCase): 'url08': ('{% url "метка_оператора" v %}', {'v': 'Ω'}, '/%D0%AE%D0%BD%D0%B8%D0%BA%D0%BE%D0%B4/%CE%A9/'), 'url09': ('{% url "метка_оператора_2" tag=v %}', {'v': 'Ω'}, '/%D0%AE%D0%BD%D0%B8%D0%BA%D0%BE%D0%B4/%CE%A9/'), 'url10': ('{% url "template_tests.views.client_action" id=client.id action="two words" %}', {'client': {'id': 1}}, '/client/1/two%20words/'), - 'url11': ('{% url "template_tests.views.client_action" id=client.id action="==" %}', {'client': {'id': 1}}, '/client/1/%3D%3D/'), - 'url12': ('{% url "template_tests.views.client_action" id=client.id action="," %}', {'client': {'id': 1}}, '/client/1/%2C/'), + 'url11': ('{% url "template_tests.views.client_action" id=client.id action="==" %}', {'client': {'id': 1}}, '/client/1/==/'), + 'url12': ('{% url "template_tests.views.client_action" id=client.id action="!$&\'()*+,;=~:@," %}', {'client': {'id': 1}}, '/client/1/!$&\'()*+,;=~:@,/'), 'url13': ('{% url "template_tests.views.client_action" id=client.id action=arg|join:"-" %}', {'client': {'id': 1}, 'arg': ['a', 'b']}, '/client/1/a-b/'), 'url14': ('{% url "template_tests.views.client_action" client.id arg|join:"-" %}', {'client': {'id': 1}, 'arg': ['a', 'b']}, '/client/1/a-b/'), 'url15': ('{% url "template_tests.views.client_action" 12 "test" %}', {}, '/client/12/test/'), - 'url18': ('{% url "template_tests.views.client" "1,2" %}', {}, '/client/1%2C2/'), + 'url18': ('{% url "template_tests.views.client" "1,2" %}', {}, '/client/1,2/'), 'url19': ('{% url named_url client.id %}', {'named_url': 'template_tests.views.client', 'client': {'id': 1}}, '/client/1/'), 'url20': ('{% url url_name_in_var client.id %}', {'url_name_in_var': 'named.client', 'client': {'id': 1}}, '/named-client/1/'), diff --git a/tests/urlpatterns_reverse/tests.py b/tests/urlpatterns_reverse/tests.py index 6964065f30..5603ea5f15 100644 --- a/tests/urlpatterns_reverse/tests.py +++ b/tests/urlpatterns_reverse/tests.py @@ -106,7 +106,7 @@ test_data = ( ('product', '/product/chocolate+($2.00)/', [], {'price': '2.00', 'product': 'chocolate'}), ('headlines', '/headlines/2007.5.21/', [], dict(year=2007, month=5, day=21)), ('windows', r'/windows_path/C:%5CDocuments%20and%20Settings%5Cspam/', [], dict(drive_name='C', path=r'Documents and Settings\spam')), - ('special', r'/special_chars/%2B%5C%24%2A/', [r'+\$*'], {}), + ('special', r'/special_chars/~@+%5C$*%7C/', [r'~@+\$*|'], {}), ('special', r'/special_chars/some%20resource/', [r'some resource'], {}), ('special', r'/special_chars/10%25%20complete/', [r'10% complete'], {}), ('special', r'/special_chars/some%20resource/', [], {'chars': r'some resource'}),