Fixed #30747 -- Renamed is_safe_url() to url_has_allowed_host_and_scheme().
This commit is contained in:
parent
13a8884a08
commit
4f61810751
|
@ -17,7 +17,9 @@ from django.http import HttpResponseRedirect, QueryDict
|
||||||
from django.shortcuts import resolve_url
|
from django.shortcuts import resolve_url
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.http import is_safe_url, urlsafe_base64_decode
|
from django.utils.http import (
|
||||||
|
url_has_allowed_host_and_scheme, urlsafe_base64_decode,
|
||||||
|
)
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.cache import never_cache
|
from django.views.decorators.cache import never_cache
|
||||||
from django.views.decorators.csrf import csrf_protect
|
from django.views.decorators.csrf import csrf_protect
|
||||||
|
@ -70,7 +72,7 @@ class LoginView(SuccessURLAllowedHostsMixin, FormView):
|
||||||
self.redirect_field_name,
|
self.redirect_field_name,
|
||||||
self.request.GET.get(self.redirect_field_name, '')
|
self.request.GET.get(self.redirect_field_name, '')
|
||||||
)
|
)
|
||||||
url_is_safe = is_safe_url(
|
url_is_safe = url_has_allowed_host_and_scheme(
|
||||||
url=redirect_to,
|
url=redirect_to,
|
||||||
allowed_hosts=self.get_success_url_allowed_hosts(),
|
allowed_hosts=self.get_success_url_allowed_hosts(),
|
||||||
require_https=self.request.is_secure(),
|
require_https=self.request.is_secure(),
|
||||||
|
@ -138,7 +140,7 @@ class LogoutView(SuccessURLAllowedHostsMixin, TemplateView):
|
||||||
self.redirect_field_name,
|
self.redirect_field_name,
|
||||||
self.request.GET.get(self.redirect_field_name)
|
self.request.GET.get(self.redirect_field_name)
|
||||||
)
|
)
|
||||||
url_is_safe = is_safe_url(
|
url_is_safe = url_has_allowed_host_and_scheme(
|
||||||
url=next_page,
|
url=next_page,
|
||||||
allowed_hosts=self.get_success_url_allowed_hosts(),
|
allowed_hosts=self.get_success_url_allowed_hosts(),
|
||||||
require_https=self.request.is_secure(),
|
require_https=self.request.is_secure(),
|
||||||
|
|
|
@ -294,15 +294,18 @@ def is_same_domain(host, pattern):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_safe_url(url, allowed_hosts, require_https=False):
|
def url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False):
|
||||||
"""
|
"""
|
||||||
Return ``True`` if the url is a safe redirection (i.e. it doesn't point to
|
Return ``True`` if the url uses an allowed host and a safe scheme.
|
||||||
a different host and uses a safe scheme).
|
|
||||||
|
|
||||||
Always return ``False`` on an empty url.
|
Always return ``False`` on an empty url.
|
||||||
|
|
||||||
If ``require_https`` is ``True``, only 'https' will be considered a valid
|
If ``require_https`` is ``True``, only 'https' will be considered a valid
|
||||||
scheme, as opposed to 'http' and 'https' with the default, ``False``.
|
scheme, as opposed to 'http' and 'https' with the default, ``False``.
|
||||||
|
|
||||||
|
Note: "True" doesn't entail that a URL is "safe". It may still be e.g.
|
||||||
|
quoted incorrectly. Ensure to also use django.utils.encoding.iri_to_uri()
|
||||||
|
on the path component of untrusted URLs.
|
||||||
"""
|
"""
|
||||||
if url is not None:
|
if url is not None:
|
||||||
url = url.strip()
|
url = url.strip()
|
||||||
|
@ -314,8 +317,19 @@ def is_safe_url(url, allowed_hosts, require_https=False):
|
||||||
allowed_hosts = {allowed_hosts}
|
allowed_hosts = {allowed_hosts}
|
||||||
# Chrome treats \ completely as / in paths but it could be part of some
|
# Chrome treats \ completely as / in paths but it could be part of some
|
||||||
# basic auth credentials so we need to check both URLs.
|
# basic auth credentials so we need to check both URLs.
|
||||||
return (_is_safe_url(url, allowed_hosts, require_https=require_https) and
|
return (
|
||||||
_is_safe_url(url.replace('\\', '/'), allowed_hosts, require_https=require_https))
|
_url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=require_https) and
|
||||||
|
_url_has_allowed_host_and_scheme(url.replace('\\', '/'), allowed_hosts, require_https=require_https)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_url(url, allowed_hosts, require_https=False):
|
||||||
|
warnings.warn(
|
||||||
|
'django.utils.http.is_safe_url() is deprecated in favor of '
|
||||||
|
'url_has_allowed_host_and_scheme().',
|
||||||
|
RemovedInDjango40Warning, stacklevel=2,
|
||||||
|
)
|
||||||
|
return url_has_allowed_host_and_scheme(url, allowed_hosts, require_https)
|
||||||
|
|
||||||
|
|
||||||
# Copied from urllib.parse.urlparse() but uses fixed urlsplit() function.
|
# Copied from urllib.parse.urlparse() but uses fixed urlsplit() function.
|
||||||
|
@ -367,7 +381,7 @@ def _urlsplit(url, scheme='', allow_fragments=True):
|
||||||
return _coerce_result(v)
|
return _coerce_result(v)
|
||||||
|
|
||||||
|
|
||||||
def _is_safe_url(url, allowed_hosts, require_https=False):
|
def _url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False):
|
||||||
# Chrome considers any URL with more than two slashes to be absolute, but
|
# Chrome considers any URL with more than two slashes to be absolute, but
|
||||||
# urlparse is not so flexible. Treat any url with three slashes as unsafe.
|
# urlparse is not so flexible. Treat any url with three slashes as unsafe.
|
||||||
if url.startswith('///'):
|
if url.startswith('///'):
|
||||||
|
|
|
@ -10,7 +10,7 @@ from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
||||||
from django.template import Context, Engine
|
from django.template import Context, Engine
|
||||||
from django.urls import translate_url
|
from django.urls import translate_url
|
||||||
from django.utils.formats import get_format
|
from django.utils.formats import get_format
|
||||||
from django.utils.http import is_safe_url
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.utils.translation import (
|
from django.utils.translation import (
|
||||||
LANGUAGE_SESSION_KEY, check_for_language, get_language,
|
LANGUAGE_SESSION_KEY, check_for_language, get_language,
|
||||||
)
|
)
|
||||||
|
@ -32,11 +32,17 @@ def set_language(request):
|
||||||
any state.
|
any state.
|
||||||
"""
|
"""
|
||||||
next = request.POST.get('next', request.GET.get('next'))
|
next = request.POST.get('next', request.GET.get('next'))
|
||||||
if ((next or not request.is_ajax()) and
|
if (
|
||||||
not is_safe_url(url=next, allowed_hosts={request.get_host()}, require_https=request.is_secure())):
|
(next or not request.is_ajax()) and
|
||||||
|
not url_has_allowed_host_and_scheme(
|
||||||
|
url=next, allowed_hosts={request.get_host()}, require_https=request.is_secure(),
|
||||||
|
)
|
||||||
|
):
|
||||||
next = request.META.get('HTTP_REFERER')
|
next = request.META.get('HTTP_REFERER')
|
||||||
next = next and unquote(next) # HTTP_REFERER may be encoded.
|
next = next and unquote(next) # HTTP_REFERER may be encoded.
|
||||||
if not is_safe_url(url=next, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
|
if not url_has_allowed_host_and_scheme(
|
||||||
|
url=next, allowed_hosts={request.get_host()}, require_https=request.is_secure(),
|
||||||
|
):
|
||||||
next = '/'
|
next = '/'
|
||||||
response = HttpResponseRedirect(next) if next else HttpResponse(status=204)
|
response = HttpResponseRedirect(next) if next else HttpResponse(status=204)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|
|
@ -32,6 +32,8 @@ details on these changes.
|
||||||
|
|
||||||
* ``django.utils.text.unescape_entities()`` will be removed.
|
* ``django.utils.text.unescape_entities()`` will be removed.
|
||||||
|
|
||||||
|
* ``django.utils.http.is_safe_url()`` will be removed.
|
||||||
|
|
||||||
.. _deprecation-removed-in-3.1:
|
.. _deprecation-removed-in-3.1:
|
||||||
|
|
||||||
3.1
|
3.1
|
||||||
|
|
|
@ -601,6 +601,14 @@ Miscellaneous
|
||||||
:func:`html.unescape`. Note that unlike ``unescape_entities()``,
|
:func:`html.unescape`. Note that unlike ``unescape_entities()``,
|
||||||
``html.unescape()`` evaluates lazy strings immediately.
|
``html.unescape()`` evaluates lazy strings immediately.
|
||||||
|
|
||||||
|
* To avoid possible confusion as to effective scope, the private internal
|
||||||
|
utility ``is_safe_url()`` is renamed to
|
||||||
|
``url_has_allowed_host_and_scheme()``. That a URL has an allowed host and
|
||||||
|
scheme doesn't in general imply that it's "safe". It may still be quoted
|
||||||
|
incorrectly, for example. Ensure to also use
|
||||||
|
:func:`~django.utils.encoding.iri_to_uri` on the path component of untrusted
|
||||||
|
URLs.
|
||||||
|
|
||||||
.. _removed-features-3.0:
|
.. _removed-features-3.0:
|
||||||
|
|
||||||
Features removed in 3.0
|
Features removed in 3.0
|
||||||
|
|
|
@ -7,8 +7,8 @@ from django.utils.deprecation import RemovedInDjango40Warning
|
||||||
from django.utils.http import (
|
from django.utils.http import (
|
||||||
base36_to_int, escape_leading_slashes, http_date, int_to_base36,
|
base36_to_int, escape_leading_slashes, http_date, int_to_base36,
|
||||||
is_safe_url, is_same_domain, parse_etags, parse_http_date, quote_etag,
|
is_safe_url, is_same_domain, parse_etags, parse_http_date, quote_etag,
|
||||||
urlencode, urlquote, urlquote_plus, urlsafe_base64_decode,
|
url_has_allowed_host_and_scheme, urlencode, urlquote, urlquote_plus,
|
||||||
urlsafe_base64_encode, urlunquote, urlunquote_plus,
|
urlsafe_base64_decode, urlsafe_base64_encode, urlunquote, urlunquote_plus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -128,7 +128,7 @@ class Base36IntTests(SimpleTestCase):
|
||||||
self.assertEqual(base36_to_int(b36), n)
|
self.assertEqual(base36_to_int(b36), n)
|
||||||
|
|
||||||
|
|
||||||
class IsSafeURLTests(unittest.TestCase):
|
class IsSafeURLTests(SimpleTestCase):
|
||||||
def test_bad_urls(self):
|
def test_bad_urls(self):
|
||||||
bad_urls = (
|
bad_urls = (
|
||||||
'http://example.com',
|
'http://example.com',
|
||||||
|
@ -164,7 +164,10 @@ class IsSafeURLTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
for bad_url in bad_urls:
|
for bad_url in bad_urls:
|
||||||
with self.subTest(url=bad_url):
|
with self.subTest(url=bad_url):
|
||||||
self.assertIs(is_safe_url(bad_url, allowed_hosts={'testserver', 'testserver2'}), False)
|
self.assertIs(
|
||||||
|
url_has_allowed_host_and_scheme(bad_url, allowed_hosts={'testserver', 'testserver2'}),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_good_urls(self):
|
def test_good_urls(self):
|
||||||
good_urls = (
|
good_urls = (
|
||||||
|
@ -181,21 +184,27 @@ class IsSafeURLTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
for good_url in good_urls:
|
for good_url in good_urls:
|
||||||
with self.subTest(url=good_url):
|
with self.subTest(url=good_url):
|
||||||
self.assertIs(is_safe_url(good_url, allowed_hosts={'otherserver', 'testserver'}), True)
|
self.assertIs(
|
||||||
|
url_has_allowed_host_and_scheme(good_url, allowed_hosts={'otherserver', 'testserver'}),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_basic_auth(self):
|
def test_basic_auth(self):
|
||||||
# Valid basic auth credentials are allowed.
|
# Valid basic auth credentials are allowed.
|
||||||
self.assertIs(is_safe_url(r'http://user:pass@testserver/', allowed_hosts={'user:pass@testserver'}), True)
|
self.assertIs(
|
||||||
|
url_has_allowed_host_and_scheme(r'http://user:pass@testserver/', allowed_hosts={'user:pass@testserver'}),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_no_allowed_hosts(self):
|
def test_no_allowed_hosts(self):
|
||||||
# A path without host is allowed.
|
# A path without host is allowed.
|
||||||
self.assertIs(is_safe_url('/confirm/me@example.com', allowed_hosts=None), True)
|
self.assertIs(url_has_allowed_host_and_scheme('/confirm/me@example.com', allowed_hosts=None), True)
|
||||||
# Basic auth without host is not allowed.
|
# Basic auth without host is not allowed.
|
||||||
self.assertIs(is_safe_url(r'http://testserver\@example.com', allowed_hosts=None), False)
|
self.assertIs(url_has_allowed_host_and_scheme(r'http://testserver\@example.com', allowed_hosts=None), False)
|
||||||
|
|
||||||
def test_allowed_hosts_str(self):
|
def test_allowed_hosts_str(self):
|
||||||
self.assertIs(is_safe_url('http://good.com/good', allowed_hosts='good.com'), True)
|
self.assertIs(url_has_allowed_host_and_scheme('http://good.com/good', allowed_hosts='good.com'), True)
|
||||||
self.assertIs(is_safe_url('http://good.co/evil', allowed_hosts='good.com'), False)
|
self.assertIs(url_has_allowed_host_and_scheme('http://good.co/evil', allowed_hosts='good.com'), False)
|
||||||
|
|
||||||
def test_secure_param_https_urls(self):
|
def test_secure_param_https_urls(self):
|
||||||
secure_urls = (
|
secure_urls = (
|
||||||
|
@ -205,7 +214,10 @@ class IsSafeURLTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
for url in secure_urls:
|
for url in secure_urls:
|
||||||
with self.subTest(url=url):
|
with self.subTest(url=url):
|
||||||
self.assertIs(is_safe_url(url, allowed_hosts={'example.com'}, require_https=True), True)
|
self.assertIs(
|
||||||
|
url_has_allowed_host_and_scheme(url, allowed_hosts={'example.com'}, require_https=True),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_secure_param_non_https_urls(self):
|
def test_secure_param_non_https_urls(self):
|
||||||
insecure_urls = (
|
insecure_urls = (
|
||||||
|
@ -215,7 +227,18 @@ class IsSafeURLTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
for url in insecure_urls:
|
for url in insecure_urls:
|
||||||
with self.subTest(url=url):
|
with self.subTest(url=url):
|
||||||
self.assertIs(is_safe_url(url, allowed_hosts={'example.com'}, require_https=True), False)
|
self.assertIs(
|
||||||
|
url_has_allowed_host_and_scheme(url, allowed_hosts={'example.com'}, require_https=True),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_is_safe_url_deprecated(self):
|
||||||
|
msg = (
|
||||||
|
'django.utils.http.is_safe_url() is deprecated in favor of '
|
||||||
|
'url_has_allowed_host_and_scheme().'
|
||||||
|
)
|
||||||
|
with self.assertWarnsMessage(RemovedInDjango40Warning, msg):
|
||||||
|
is_safe_url('https://example.com', allowed_hosts={'example.com'})
|
||||||
|
|
||||||
|
|
||||||
class URLSafeBase64Tests(unittest.TestCase):
|
class URLSafeBase64Tests(unittest.TestCase):
|
||||||
|
|
Loading…
Reference in New Issue