diff --git a/django/core/urlresolvers.py b/django/core/urlresolvers.py index ec2202edb..ecc14622a 100644 --- a/django/core/urlresolvers.py +++ b/django/core/urlresolvers.py @@ -22,6 +22,7 @@ from django.utils.http import urlquote from django.utils.module_loading import module_has_submodule from django.utils.regex_helper import normalize from django.utils import six, lru_cache +from django.utils.http import escape_leading_slashes from django.utils.translation import get_language @@ -449,9 +450,7 @@ class RegexURLResolver(LocaleRegexProvider): candidate_subs = dict((k, urlquote(v)) for (k, v) in candidate_subs.items()) url = candidate_pat % candidate_subs # Don't allow construction of scheme relative urls. - if url.startswith('//'): - url = '/%%2F%s' % url[2:] - return url + return escape_leading_slashes(url) # 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 # error messages. diff --git a/django/middleware/common.py b/django/middleware/common.py index ce17a02c9..bc9da4997 100644 --- a/django/middleware/common.py +++ b/django/middleware/common.py @@ -9,7 +9,7 @@ from django.core import urlresolvers from django import http from django.utils.deprecation import RemovedInDjango18Warning from django.utils.encoding import force_text -from django.utils.http import urlquote +from django.utils.http import urlquote, escape_leading_slashes from django.utils import six @@ -72,6 +72,10 @@ class CommonMiddleware(object): if (not urlresolvers.is_valid_path(request.path_info, urlconf) and urlresolvers.is_valid_path("%s/" % request.path_info, urlconf)): new_url[1] = new_url[1] + '/' + # Prevent construction of scheme relative urls. + if 'security' in new_url[1]: + new_url[1] = escape_leading_slashes(new_url[1]) if settings.DEBUG and request.method == 'POST': raise RuntimeError(("" "You called this URL via POST, but the URL doesn't end " diff --git a/django/utils/http.py b/django/utils/http.py index 7a931a954..8ccb27ec5 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -367,3 +367,14 @@ def _is_safe_url(url, host): return False return ((not url_info.netloc or url_info.netloc == host) and (not url_info.scheme or url_info.scheme in ['http', 'https'])) + + +def escape_leading_slashes(url): + """ + If redirecting to an absolute path (two leading slashes), a slash must be + escaped to prevent browsers from handling the path as schemaless and + redirecting to another host. + """ + if url.startswith('//'): + url = '/%2F{}'.format(url[2:]) + return url diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py index 9cda309bf..6b3b08860 100644 --- a/tests/middleware/tests.py +++ b/tests/middleware/tests.py @@ -12,7 +12,8 @@ from django.conf import settings from django.core import mail from django.db import (transaction, connections, DEFAULT_DB_ALIAS, IntegrityError) -from django.http import HttpRequest, HttpResponse, StreamingHttpResponse +from django.http import (HttpRequest, HttpResponse, HttpResponseNotFound, + StreamingHttpResponse) from django.middleware.clickjacking import XFrameOptionsMiddleware from django.middleware.common import CommonMiddleware, BrokenLinkEmailsMiddleware from django.middleware.http import ConditionalGetMiddleware @@ -107,6 +108,26 @@ class CommonMiddlewareTest(TestCase): r.url, 'http://testserver/needsquoting%23/') + @override_settings(APPEND_SLASH=True) + def test_append_slash_leading_slashes(self): + """ + Paths starting with two slashes are escaped to prevent open redirects. + If there's a URL pattern that allows paths to start with two slashes, a + request with path //evil.com must not redirect to //evil.com/ (appended + slash) which is a schemaless absolute URL. The browser would navigate + to evil.com/. + """ + rf = RequestFactory() + # Use 4 slashes because of RequestFactory behavior. + request = rf.get('////evil.com/security') + response = HttpResponseNotFound() + r = CommonMiddleware().process_request(request) + self.assertEqual(r.status_code, 301) + self.assertEqual(r.url, '/%2Fevil.com/security/') + r = CommonMiddleware().process_response(request, response) + self.assertEqual(r.status_code, 301) + self.assertEqual(r.url, '/%2Fevil.com/security/') + @override_settings(APPEND_SLASH=False, PREPEND_WWW=True) def test_prepend_www(self): request = self._get_request('path/') diff --git a/tests/middleware/urls.py b/tests/middleware/urls.py index 217e3ae03..378eb47bb 100644 --- a/tests/middleware/urls.py +++ b/tests/middleware/urls.py @@ -4,4 +4,6 @@ urlpatterns = patterns('', (r'^noslash$', 'view'), (r'^slash/$', 'view'), (r'^needsquoting#/$', 'view'), + # Accepts paths with two leading slashes. + (r'^(.+)/security/$', 'empty_view'), ) diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py index a1b34fa2b..5804e7293 100644 --- a/tests/utils_tests/test_html.py +++ b/tests/utils_tests/test_html.py @@ -6,7 +6,7 @@ import os from unittest import TestCase import warnings -from django.utils import html, safestring +from django.utils import html, http, safestring from django.utils._os import upath from django.utils.deprecation import RemovedInDjango18Warning from django.utils.encoding import force_text @@ -225,3 +225,13 @@ class TestUtilsHtml(TestCase): ) for value in tests: self.assertEqual(html.urlize(value), value) + + +class EscapeLeadingSlashesTests(TestCase): + def test(self): + tests = ( + ('//example.com', '/%2Fexample.com'), + ('//', '/%2F'), + ) + for url, expected in tests: + self.assertEqual(http.escape_leading_slashes(url), expected)