CVE-2021-25963: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: In Shuup, versions 1.6.0 through 2.10.8 are vulnerable to reflected Cross-Site Scripting (XSS) that allows execution of arbitrary javascript code on a victim browser. This vulnerability exists due to the error page contents not escaped.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2021-09-30T08:15Z
Repository: https://github.com/shuup/shuup
Language: Python
Files changed: CHANGELOG.md, shuup/admin/utils/urls.py, shuup/core/basket/command_dispatcher.py, shuup/front/checkout/_process.py, shuup/front/urls.py, shuup/utils/excs.py, shuup/xtheme/urls.py, shuup/xtheme/views/command.py, shuup/xtheme/views/editor.py, shuup/xtheme/views/extra.py
Commit date: 2021-07-07 09:30:55 -0300
Commit message: General: fix critical views that can be subject of XSS attacks

The views were returning not escaped content which is unsafe

Thanks to Daniel Elkabes who reported the issue

Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1319b7db1..e19c5e99e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## Unreleased
 
 List all changes after the last release here (newer on top). Each change on a separate bullet point line
 
+### Fixed
+
+- General: fix critical vulnerability on views that were returning not escaped content making it open to XSS attacks
+
+### Changed
+
+- Admin: hide email template button based on permission
+- Reports: improve log when an importer fails
+
 ## [2.10.8] - 2021-06-30
 
 ### Changed
 
 - Pull translations from Transifex
diff --git a/shuup/admin/utils/urls.py b/shuup/admin/utils/urls.py
index c5b840d08..1bd15f22d 100644
--- a/shuup/admin/utils/urls.py
+++ b/shuup/admin/utils/urls.py
@@ -14,10 +14,11 @@ import warnings
 from django.conf import settings
 from django.contrib.auth.views import redirect_to_login
 from django.core.exceptions import ImproperlyConfigured
 from django.http.response import HttpResponseForbidden
 from django.utils.encoding import force_str, force_text
+from django.utils.html import escape
 from django.utils.http import urlencode
 from django.utils.translation import ugettext_lazy as _
 
 from shuup.admin.module_registry import get_modules
 from shuup.admin.shop_provider import get_shop
@@ -62,11 +63,11 @@ class AdminRegexURLPattern(URLPattern):
         resp = redirect_to_login(next=request.path, login_url=login_url)
         if is_authenticated(request.user):
             # Instead of redirecting to the login page, let the user know what's wrong with
             # a helpful link.
             raise (
-                Problem(_("Can't view this page. %(reason)s") % {"reason": reason}).with_link(
+                Problem(_("Can't view this page. %(reason)s") % {"reason": escape(reason)}).with_link(
                     url=resp.url, title=_("Log in with different credentials...")
                 )
             )
         return resp
 
diff --git a/shuup/core/basket/command_dispatcher.py b/shuup/core/basket/command_dispatcher.py
index 9d3282e66..d1d02d803 100644
--- a/shuup/core/basket/command_dispatcher.py
+++ b/shuup/core/basket/command_dispatcher.py
@@ -9,10 +9,11 @@ from __future__ import unicode_literals
 
 import six
 from django.core.exceptions import ValidationError
 from django.http import HttpResponseRedirect, JsonResponse
 from django.shortcuts import redirect
+from django.utils.html import escape
 from django.utils.translation import ugettext_lazy as _
 
 from shuup.apps.provides import get_provide_objects
 from shuup.core.basket import commands
 from shuup.core.basket.command_middleware import BaseBasketCommandMiddleware
@@ -66,11 +67,11 @@ class BasketCommandDispatcher(object):
 
         kwargs = kwargs or dict(six.iteritems(self.request.POST))
         try:
             handler = self.get_command_handler(command)
             if not handler or not callable(handler):
-                raise Problem(_("Error! Invalid command `%s`.") % command)
+                raise Problem(_("Error! Invalid command `%s`.") % escape(command))
             kwargs.pop("csrfmiddlewaretoken", None)  # The CSRF token should never be passed as a kwarg
             kwargs.pop("command", None)  # Nor the command
             kwargs.update(request=self.request, basket=self.basket)
             kwargs = self.preprocess_kwargs(command, kwargs)
 
diff --git a/shuup/front/checkout/_process.py b/shuup/front/checkout/_process.py
index c8143aae3..6190b4cc7 100644
--- a/shuup/front/checkout/_process.py
+++ b/shuup/front/checkout/_process.py
@@ -7,10 +7,11 @@
 from __future__ import unicode_literals
 
 from collections import OrderedDict
 from django.core.exceptions import ImproperlyConfigured
 from django.http.response import Http404
+from django.utils.html import escape
 
 from shuup.front.basket import get_basket
 from shuup.utils.django_compat import reverse
 from shuup.utils.importing import load
 
@@ -73,11 +74,11 @@ class CheckoutProcess(object):
                 found = True  # We're at or past the requested phase
                 if not phase.should_skip():
                     return phase
             if not phase.should_skip() and not phase.is_valid():  # A past phase is not valid, that's the current one
                 return phase
-        raise Http404("Error! Phase with identifier `%s` not found." % requested_phase_identifier)  # pragma: no cover
+        raise Http404("Error! Phase with identifier `%s` not found." % escape(requested_phase_identifier))
 
     def _get_next_phase(self, phases, current_phase, target_phase):
         found = False
         for phase in phases:
             if phase.identifier == current_phase.identifier:
diff --git a/shuup/front/urls.py b/shuup/front/urls.py
index 7a6b792fe..74a2fe731 100644
--- a/shuup/front/urls.py
+++ b/shuup/front/urls.py
@@ -8,10 +8,11 @@
 from __future__ import unicode_literals
 
 from django.conf.urls import url
 from django.contrib.auth.decorators import login_required
 from django.http.response import HttpResponse
+from django.utils.html import escape
 from django.views.decorators.csrf import csrf_exempt
 from django.views.i18n import set_language
 from itertools import chain
 
 from shuup.apps.provides import get_provide_objects
@@ -35,11 +36,11 @@ from .views.upload import media_upload
 
 # TODO: Check _not_here_yet URLs in this file
 
 
 def _not_here_yet(request, *args, **kwargs):
-    return HttpResponse("Not here yet: %s (%r, %r)" % (request.path, args, kwargs), status=410)
+    return HttpResponse("Not here yet: %s (%r, %r)" % (request.path, escape(args), escape(kwargs)), status=410)
 
 
 # Use a different js catalog function in front urlpatterns to prevent forcing
 # the shop language settings in admin js catalog.
 def front_javascript_catalog_all(request, domain="djangojs"):
diff --git a/shuup/utils/excs.py b/shuup/utils/excs.py
index b0a28365d..a6690110c 100644
--- a/shuup/utils/excs.py
+++ b/shuup/utils/excs.py
@@ -1,6 +1,7 @@
 from django.core.exceptions import ValidationError
+from django.utils.html import escape
 
 from shuup.utils.django_compat import force_text
 
 # This file is part of Shuup.
 #
@@ -59,12 +60,12 @@ def extract_messages(obj_list):
     :rtype: Iterable[str]
     """
     for obj in obj_list:
         if isinstance(obj, ValidationError):
             for msg in obj.messages:
-                yield force_text(msg)
+                yield escape(force_text(msg))
             continue
         if isinstance(obj, Exception):
             if len(obj.args):
-                yield force_text(obj.args[0])
+                yield escape(force_text(obj.args[0]))
                 continue
-        yield force_text(obj)
+        yield escape(force_text(obj))
diff --git a/shuup/xtheme/urls.py b/shuup/xtheme/urls.py
index ac9149b59..c154e5d34 100644
--- a/shuup/xtheme/urls.py
+++ b/shuup/xtheme/urls.py
@@ -17,11 +17,11 @@ from shuup.xtheme.views.plugins import (
     get_prouduct_selections_highlight,
 )
 
 urlpatterns = [
     url(r"^xtheme/editor/$", EditorView.as_view(), name="xtheme_editor"),
-    url(r"^xtheme/(?P<view>.+)/*$", extra_view_dispatch, name="xtheme_extra_view"),
+    url(r"^xtheme/(?P<view>.+)/?$", extra_view_dispatch, name="xtheme_extra_view"),
     url(r"^xtheme/$", command_dispatch, name="xtheme"),
     url(
         r"^xtheme-prod-hl/(?P<plugin_type>.*)/(?P<cutoff_days>\d+)/(?P<count>\d+)/(?P<cache_timeout>\d+)/$",
         get_product_highlight,
         name="xtheme-product-highlight",
diff --git a/shuup/xtheme/views/command.py b/shuup/xtheme/views/command.py
index 59ada4baa..91463cbaf 100644
--- a/shuup/xtheme/views/command.py
+++ b/shuup/xtheme/views/command.py
@@ -4,10 +4,11 @@
 # Copyright (c) 2012-2021, Shuup Commerce Inc. All rights reserved.
 #
 # This source code is licensed under the OSL-3.0 license found in the
 # LICENSE file in the root directory of this source tree.
 from django.http.response import HttpResponseRedirect
+from django.utils.html import escape
 
 from shuup.utils.excs import Problem
 from shuup.xtheme.editing import set_edit_mode
 
 
@@ -40,6 +41,6 @@ def command_dispatch(request):
     command = request.POST.get("command")
     if command:
         response = handle_command(request, command)
         if response:
             return response
-    raise Problem("Error! Unknown command: `%r`" % command)
+    raise Problem("Error! Unknown command: `%r`" % escape(command))
diff --git a/shuup/xtheme/views/editor.py b/shuup/xtheme/views/editor.py
index 957685df6..c6f8d709e 100644
--- a/shuup/xtheme/views/editor.py
+++ b/shuup/xtheme/views/editor.py
@@ -6,10 +6,11 @@
 # This source code is licensed under the OSL-3.0 license found in the
 # LICENSE file in the root directory of this source tree.
 import json
 from django.http.response import HttpResponse, HttpResponseRedirect
 from django.middleware.csrf import get_token
+from django.utils.html import escape
 from django.utils.http import urlencode
 from django.utils.translation import ugettext_lazy as _
 from django.views.generic import TemplateView
 
 from shuup.utils.excs import Problem
@@ -68,11 +69,11 @@ class EditorView(TemplateView):
     def post(self, request, *args, **kwargs):  # doccov: ignore
         command = request.POST.get("command")
         if command:
             dispatcher = getattr(self, "dispatch_%s" % command, None)
             if not callable(dispatcher):
-                raise Problem(_("Unknown command: `%s`.") % command)
+                raise Problem(_("Unknown command: `%s`.") % escape(command))
             dispatch_kwargs = dict(request.POST.items())
             rv = dispatcher(**dispatch_kwargs)
             if rv:
                 return rv
             self.request.method = "GET"  # At this point, we won't want to cause form validation
diff --git a/shuup/xtheme/views/extra.py b/shuup/xtheme/views/extra.py
index 30c260699..a50dca594 100644
--- a/shuup/xtheme/views/extra.py
+++ b/shuup/xtheme/views/extra.py
@@ -6,10 +6,11 @@
 # This source code is licensed under the OSL-3.0 license found in the
 # LICENSE file in the root directory of this source tree.
 from django.core.exceptions import ImproperlyConfigured
 from django.core.signals import setting_changed
 from django.http.response import HttpResponseNotFound
+from django.utils.html import escape
 
 from shuup.xtheme._theme import get_current_theme
 
 _VIEW_CACHE = {}
 
@@ -54,8 +55,8 @@ def extra_view_dispatch(request, view):
     :rtype: django.http.HttpResponse
     """
     theme = getattr(request, "theme", None) or get_current_theme(request.shop)
     view_func = get_view_by_name(theme, view)
     if not view_func:
-        msg = "Error! %s/%s: Not found." % (getattr(theme, "identifier", None), view)
+        msg = "Error! %s/%s: Not found." % (getattr(theme, "identifier", None), escape(view))
         return HttpResponseNotFound(msg)
     return view_func(request)

Vulnerable code:                 Problem(_("Can't view this page. %(reason)s") % {"reason": reason}).with_link(
                raise Problem(_("Error! Invalid command `%s`.") % command)
        raise Http404("Error! Phase with identifier `%s` not found." % requested_phase_identifier)  # pragma: no cover
    return HttpResponse("Not here yet: %s (%r, %r)" % (request.path, args, kwargs), status=410)
                yield force_text(msg)
                yield force_text(obj.args[0])
        yield force_text(obj)
    url(r"^xtheme/(?P<view>.+)/*$", extra_view_dispatch, name="xtheme_extra_view"),
    raise Problem("Error! Unknown command: `%r`" % command)
                raise Problem(_("Unknown command: `%s`.") % command)
        msg = "Error! %s/%s: Not found." % (getattr(theme, "identifier", None), view)
Fixed code: ### Fixed

- General: fix critical vulnerability on views that were returning not escaped content making it open to XSS attacks

### Changed

- Admin: hide email template button based on permission
- Reports: improve log when an importer fails

from django.utils.html import escape
                Problem(_("Can't view this page. %(reason)s") % {"reason": escape(reason)}).with_link(
from django.utils.html import escape
                raise Problem(_("Error! Invalid command `%s`.") % escape(command))
from django.utils.html import escape
        raise Http404("Error! Phase with identifier `%s` not found." % escape(requested_phase_identifier))
from django.utils.html import escape
    return HttpResponse("Not here yet: %s (%r, %r)" % (request.path, escape(args), escape(kwargs)), status=410)
from django.utils.html import escape
                yield escape(force_text(msg))
                yield escape(force_text(obj.args[0]))
        yield escape(force_text(obj))
    url(r"^xtheme/(?P<view>.+)/?$", extra_view_dispatch, name="xtheme_extra_view"),
from django.utils.html import escape
    raise Problem("Error! Unknown command: `%r`" % escape(command))
from django.utils.html import escape
                raise Problem(_("Unknown command: `%s`.") % escape(command))
from django.utils.html import escape
        msg = "Error! %s/%s: Not found." % (getattr(theme, "identifier", None), escape(view))