File CVE-2021-46898.patch of Package python-django-grappelli.18201
From 4ca94bcda0fa2720594506853d85e00c8212968f Mon Sep 17 00:00:00 2001
From: ksg <ksg97031@gmail.com>
Date: Thu, 30 Sep 2021 20:39:12 +0900
Subject: [PATCH] Update switch.py
This will fix issue #975 (I referred to this https://github.com/django/django/blob/main/django/views/i18n.py#L41-L45)
---
grappelli/views/switch.py | 20 +++++++++++++-------
1 file changed, 13 insertions(+), 7 deletions(-)
Index: django-grappelli-2.14.4/grappelli/views/switch.py
===================================================================
--- django-grappelli-2.14.4.orig/grappelli/views/switch.py
+++ django-grappelli-2.14.4/grappelli/views/switch.py
@@ -1,5 +1,7 @@
# coding: utf-8
+import unicodedata
+
from django.conf import settings
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
@@ -9,6 +11,8 @@ from django.http import Http404
from django.shortcuts import redirect
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _
+from urllib.parse import _coerce_args
+from urllib.parse import ParseResult, SplitResult, uses_params
from grappelli.settings import SWITCH_USER_ORIGINAL, SWITCH_USER_TARGET
@@ -18,6 +22,112 @@ try:
except ImportError:
from django.contrib.auth.models import User
+def url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False):
+ """
+ Return ``True`` if the url uses an allowed host and a safe scheme.
+
+ Always return ``False`` on an empty url.
+
+ If ``require_https`` is ``True``, only 'https' will be considered a valid
+ 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:
+ url = url.strip()
+ if not url:
+ return False
+ if allowed_hosts is None:
+ allowed_hosts = set()
+ elif isinstance(allowed_hosts, str):
+ allowed_hosts = {allowed_hosts}
+ # Chrome treats \ completely as / in paths but it could be part of some
+ # basic auth credentials so we need to check both URLs.
+ return (
+ _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 _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
+ # urlparse is not so flexible. Treat any url with three slashes as unsafe.
+ if url.startswith('///'):
+ return False
+ try:
+ url_info = _urlparse(url)
+ except ValueError: # e.g. invalid IPv6 addresses
+ return False
+ # 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.netloc and url_info.scheme:
+ 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(url[0])[0] == 'C':
+ return False
+ scheme = url_info.scheme
+ # Consider URLs without a scheme (e.g. //example.com/p) to be http.
+ if not url_info.scheme and url_info.netloc:
+ scheme = 'http'
+ valid_schemes = ['https'] if require_https else ['http', 'https']
+ return ((not url_info.netloc or url_info.netloc in allowed_hosts) and
+ (not scheme or scheme in valid_schemes))
+
+
+# Copied from urllib.parse.urlparse() but uses fixed urlsplit() function.
+def _urlparse(url, scheme='', allow_fragments=True):
+ """Parse a URL into 6 components:
+ <scheme>://<netloc>/<path>;<params>?<query>#<fragment>
+ Return a 6-tuple: (scheme, netloc, path, params, query, fragment).
+ Note that we don't break the components up in smaller bits
+ (e.g. netloc is a single string) and we don't expand % escapes."""
+ url, scheme, _coerce_result = _coerce_args(url, scheme)
+ splitresult = _urlsplit(url, scheme, allow_fragments)
+ scheme, netloc, url, query, fragment = splitresult
+ if scheme in uses_params and ';' in url:
+ url, params = _splitparams(url)
+ else:
+ params = ''
+ result = ParseResult(scheme, netloc, url, params, query, fragment)
+ return _coerce_result(result)
+
+
+# Copied from urllib.parse.urlsplit() with
+# https://github.com/python/cpython/pull/661 applied.
+def _urlsplit(url, scheme='', allow_fragments=True):
+ """Parse a URL into 5 components:
+ <scheme>://<netloc>/<path>?<query>#<fragment>
+ Return a 5-tuple: (scheme, netloc, path, query, fragment).
+ Note that we don't break the components up in smaller bits
+ (e.g. netloc is a single string) and we don't expand % escapes."""
+ url, scheme, _coerce_result = _coerce_args(url, scheme)
+ netloc = query = fragment = ''
+ i = url.find(':')
+ if i > 0:
+ for c in url[:i]:
+ if c not in scheme_chars:
+ break
+ else:
+ scheme, url = url[:i].lower(), url[i + 1:]
+
+ if url[:2] == '//':
+ netloc, url = _splitnetloc(url, 2)
+ if (('[' in netloc and ']' not in netloc) or
+ (']' in netloc and '[' not in netloc)):
+ raise ValueError("Invalid IPv6 URL")
+ if allow_fragments and '#' in url:
+ url, fragment = url.split('#', 1)
+ if '?' in url:
+ url, query = url.split('?', 1)
+ v = SplitResult(scheme, netloc, url, query, fragment)
+ return _coerce_result(v)
+
@staff_member_required
def switch_user(request, object_id):
@@ -28,7 +138,12 @@ def switch_user(request, object_id):
# check redirect
redirect_url = request.GET.get("redirect", None)
- if redirect_url is None or not redirect_url.startswith("/"):
+ if redirect_url is None or not \
+ url_has_allowed_host_and_scheme(
+ url=redirect_url,
+ allowed_hosts={request.get_host()},
+ require_https=request.is_secure(),
+ ):
raise Http404()
# check original_user
@@ -36,22 +151,22 @@ def switch_user(request, object_id):
original_user = User.objects.get(pk=session_user["id"], is_staff=True)
if not SWITCH_USER_ORIGINAL(original_user):
messages.add_message(request, messages.ERROR, _("Permission denied."))
- return redirect(request.GET.get("redirect"))
+ return redirect(redirect_url)
except ObjectDoesNotExist:
msg = _('%(name)s object with primary key %(key)r does not exist.') % {'name': "User", 'key': escape(session_user["id"])}
messages.add_message(request, messages.ERROR, msg)
- return redirect(request.GET.get("redirect"))
+ return redirect(redirect_url)
# check new user
try:
target_user = User.objects.get(pk=object_id, is_staff=True)
if target_user != original_user and not SWITCH_USER_TARGET(original_user, target_user):
messages.add_message(request, messages.ERROR, _("Permission denied."))
- return redirect(request.GET.get("redirect"))
+ return redirect(redirect_url)
except ObjectDoesNotExist:
msg = _('%(name)s object with primary key %(key)r does not exist.') % {'name': "User", 'key': escape(object_id)}
messages.add_message(request, messages.ERROR, msg)
- return redirect(request.GET.get("redirect"))
+ return redirect(redirect_url)
# find backend
if not hasattr(target_user, 'backend'):
@@ -66,4 +181,4 @@ def switch_user(request, object_id):
if original_user.id != target_user.id:
request.session["original_user"] = {"id": original_user.id, "username": original_user.get_username()}
- return redirect(request.GET.get("redirect"))
+ return redirect(redirect_url)