CVE-2023-26051: Generation of Error Message Containing Sensitive Information (CWE-209)
value: Saleor is a headless, GraphQL commerce platform delivering personalized shopping experiences. Some internal Python exceptions are not handled properly and thus are returned in API as error messages. Some messages might contain sensitive information like user email address in staff-authenticated requests.

CWE description: The product generates an error message that includes sensitive information about its environment, users, or associated data.
CVSS v3 base score: 4.3
Published: 2023-03-02T19:15Z
Repository: https://github.com/saleor/saleor
Language: Python
Files changed: saleor/graphql/account/tests/test_account.py, saleor/graphql/core/mutations.py, saleor/graphql/core/tests/test_view.py, saleor/graphql/tests/test_utils.py, saleor/graphql/utils/__init__.py
Commit date: 2023-03-02 13:00:09 +0100
Commit message: Merge pull request from GHSA-r8qr-wwg3-2r85

* Block internal exception messages in API

* Fix isort

* Add more exceptions to allowed ones

* Adjust test
Diff: diff --git a/saleor/graphql/account/tests/test_account.py b/saleor/graphql/account/tests/test_account.py
index 919f3529fe..f2e9022a18 100644
--- a/saleor/graphql/account/tests/test_account.py
+++ b/saleor/graphql/account/tests/test_account.py
@@ -5276,10 +5276,11 @@ REQUEST_PASSWORD_RESET_MUTATION = """
         requestPasswordReset(
             email: $email, redirectUrl: $redirectUrl, channel: $channel) {
             errors {
                 field
                 message
+                code
             }
         }
     }
 """
 
@@ -5514,16 +5515,14 @@ def test_account_reset_password_user_is_inactive(
         "email": customer_user.email,
         "redirectUrl": "https://www.example.com",
         "channel": channel_USD.slug,
     }
     response = user_api_client.post_graphql(REQUEST_PASSWORD_RESET_MUTATION, variables)
-    results = response.json()
-    assert "errors" in results
-    assert (
-        results["errors"][0]["message"]
-        == "Invalid token. User does not exist or is inactive."
-    )
+    content = get_graphql_content(response)
+    data = content["data"]["requestPasswordReset"]
+    assert len(data["errors"]) == 1
+    assert data["errors"][0]["code"] == AccountErrorCode.JWT_INVALID_TOKEN.name
     assert not mocked_notify.called
 
 
 @patch("saleor.plugins.manager.PluginsManager.notify")
 def test_account_reset_password_storefront_hosts_not_allowed(
diff --git a/saleor/graphql/core/mutations.py b/saleor/graphql/core/mutations.py
index 5d73aaefce..889c8736c8 100644
--- a/saleor/graphql/core/mutations.py
+++ b/saleor/graphql/core/mutations.py
@@ -17,10 +17,11 @@ from typing import (
     overload,
 )
 from uuid import UUID
 
 import graphene
+import jwt
 from django.core.exceptions import (
     NON_FIELD_ERRORS,
     ImproperlyConfigured,
     ValidationError,
 )
@@ -30,10 +31,11 @@ from django.db.models import Model, Q, QuerySet
 from django.db.models.fields.files import FileField
 from graphene import ObjectType
 from graphene.types.mutation import MutationOptions
 from graphql.error import GraphQLError
 
+from ...account.error_codes import AccountErrorCode
 from ...core.error_codes import MetadataErrorCode
 from ...core.exceptions import PermissionDenied
 from ...core.utils.events import call_event
 from ...permission.auth_filters import AuthorizationFilters
 from ...permission.enums import BasePermissionEnum
@@ -500,11 +502,18 @@ class BaseMutation(graphene.Mutation):
         return one_of_permissions_or_auth_filter_required(context, all_permissions)
 
     @classmethod
     def mutate(cls, root, info: ResolveInfo, **data):
         disallow_replica_in_context(info.context)
-        setup_context_user(info.context)
+        try:
+            setup_context_user(info.context)
+        except jwt.InvalidTokenError:
+            return cls.handle_errors(
+                ValidationError(
+                    "Invalid token", code=AccountErrorCode.JWT_INVALID_TOKEN.value
+                )
+            )
 
         if not cls.check_permissions(info.context, data=data):
             raise PermissionDenied(permissions=cls._meta.permissions)
         manager = get_plugin_manager_promise(info.context).get()
         result = manager.perform_mutation(
@@ -936,11 +945,18 @@ class BaseBulkMutation(BaseMutation):
         return count, errors
 
     @classmethod
     def mutate(cls, root, info: ResolveInfo, **data):
         disallow_replica_in_context(info.context)
-        setup_context_user(info.context)
+        try:
+            setup_context_user(info.context)
+        except jwt.InvalidTokenError:
+            return cls.handle_errors(
+                ValidationError(
+                    "Invalid token", code=AccountErrorCode.JWT_INVALID_TOKEN.value
+                )
+            )
 
         if not cls.check_permissions(info.context):
             raise PermissionDenied(permissions=cls._meta.permissions)
         manager = get_plugin_manager_promise(info.context).get()
         result = manager.perform_mutation(
diff --git a/saleor/graphql/core/tests/test_view.py b/saleor/graphql/core/tests/test_view.py
index 52735f32f4..6a72109983 100644
--- a/saleor/graphql/core/tests/test_view.py
+++ b/saleor/graphql/core/tests/test_view.py
@@ -5,10 +5,11 @@ import pytest
 from django.test import override_settings
 from graphql.execution.base import ExecutionResult
 
 from .... import __version__ as saleor_version
 from ....demo.views import EXAMPLE_QUERY
+from ....graphql.utils import INTERNAL_ERROR_MESSAGE
 from ...tests.fixtures import API_PATH
 from ...tests.utils import get_graphql_content, get_graphql_content_from_response
 from ...views import generate_cache_key
 
 
@@ -143,11 +144,11 @@ def test_graphql_execution_exception(monkeypatch, api_client):
 
     monkeypatch.setattr("graphql.backend.core.execute_and_validate", mocked_execute)
     response = api_client.post_graphql("{ shop { name }}")
     assert response.status_code == 400
     content = get_graphql_content_from_response(response)
-    assert content["errors"][0]["message"] == "Spanish inquisition"
+    assert content["errors"][0]["message"] == INTERNAL_ERROR_MESSAGE
 
 
 def test_invalid_query_graphql_errors_are_logged_in_another_logger(
     api_client, graphql_log_handler
 ):
diff --git a/saleor/graphql/tests/test_utils.py b/saleor/graphql/tests/test_utils.py
index 50bdc9f24e..a43effcc6f 100644
--- a/saleor/graphql/tests/test_utils.py
+++ b/saleor/graphql/tests/test_utils.py
@@ -1,9 +1,11 @@
 import re
 
+from django.test import override_settings
 from graphql.utils import schema_printer
 
+from ..utils import ALLOWED_ERRORS, INTERNAL_ERROR_MESSAGE, format_error
 from .utils import get_graphql_content
 
 
 def test_multiple_interface_separator_in_schema(api_client):
     query = """
@@ -23,5 +25,27 @@ def test_multiple_interface_separator_in_schema(api_client):
     assert ampersand_separated_interfaces
 
 
 def test_graphql_core_contains_patched_function():
     assert hasattr(schema_printer, "_print_object")
+
+
+@override_settings(DEBUG=False)
+def test_format_error_hides_internal_error_msg_in_production_mode():
+    error = ValueError("Example error")
+    result = format_error(error, ())
+    assert result["message"] == INTERNAL_ERROR_MESSAGE
+
+
+@override_settings(DEBUG=False)
+def test_format_error_prints_allowed_errors():
+    error_cls = ALLOWED_ERRORS[0]
+    error = error_cls("Example error")
+    result = format_error(error, ())
+    assert result["message"] == str(error)
+
+
+@override_settings(DEBUG=True)
+def test_format_error_prints_internal_error_msg_in_debug_mode():
+    error = ValueError("Example error")
+    result = format_error(error, ())
+    assert result["message"] == str(error)
diff --git a/saleor/graphql/utils/__init__.py b/saleor/graphql/utils/__init__.py
index 5d93a39e84..a434a17299 100644
--- a/saleor/graphql/utils/__init__.py
+++ b/saleor/graphql/utils/__init__.py
@@ -4,21 +4,28 @@ import traceback
 from typing import TYPE_CHECKING, Any, Dict, Iterable, Union
 from uuid import UUID
 
 import graphene
 from django.conf import settings
+from django.core.exceptions import ValidationError
 from django.db.models import Q, Value
 from django.db.models.functions import Concat
 from graphql import GraphQLDocument
 from graphql.error import GraphQLError
 from graphql.error import format_error as format_graphql_error
 
 from ...account.models import User
 from ...app.models import App
+from ...core.exceptions import (
+    CircularSubscriptionSyncEvent,
+    PermissionDenied,
+    ReadOnlyException,
+)
 from ..core.enums import PermissionEnum
 from ..core.types import TYPES_WITH_DOUBLE_ID_AVAILABLE, Permission
 from ..core.utils import from_global_id_or_error
+from ..core.validators.query_cost import QueryCostError
 
 if TYPE_CHECKING:
     from ..core import SaleorContext
 
 unhandled_errors_logger = logging.getLogger("saleor.graphql.errors.unhandled")
@@ -31,10 +38,22 @@ ERROR_COULD_NO_RESOLVE_GLOBAL_ID = (
 REVERSED_DIRECTION = {
     "-": "",
     "": "-",
 }
 
+# List of error types of which messages can be returned in the GraphQL API.
+ALLOWED_ERRORS = [
+    CircularSubscriptionSyncEvent,
+    GraphQLError,
+    PermissionDenied,
+    ReadOnlyException,
+    ValidationError,
+    QueryCostError,
+]
+
+INTERNAL_ERROR_MESSAGE = "Internal Server Error"
+
 
 def resolve_global_ids_to_primary_keys(
     ids: Iterable[str], graphene_type=None, raise_error: bool = False
 ):
     pks = []
@@ -266,10 +285,16 @@ def format_error(error, handled_exceptions):
     if isinstance(exc, handled_exceptions):
         handled_errors_logger.info("A query had an error", exc_info=exc)
     else:
         unhandled_errors_logger.error("A query failed unexpectedly", exc_info=exc)
 
+    # If DEBUG mode is disabled we allow only certain error messages to be returned in
+    # the API. This prevents from leaking internals that might be included in Python
+    # exceptions' error messages.
+    if type(exc) not in ALLOWED_ERRORS and not settings.DEBUG:
+        result["message"] = INTERNAL_ERROR_MESSAGE
+
     result["extensions"]["exception"] = {"code": type(exc).__name__}
     if settings.DEBUG:
         lines = []
 
         if isinstance(exc, BaseException):

Vulnerable code:     results = response.json()
    assert "errors" in results
    assert (
        results["errors"][0]["message"]
        == "Invalid token. User does not exist or is inactive."
    )
        setup_context_user(info.context)
        setup_context_user(info.context)
    assert content["errors"][0]["message"] == "Spanish inquisition"
Fixed code:                 code
    content = get_graphql_content(response)
    data = content["data"]["requestPasswordReset"]
    assert len(data["errors"]) == 1
    assert data["errors"][0]["code"] == AccountErrorCode.JWT_INVALID_TOKEN.name
import jwt
from ...account.error_codes import AccountErrorCode
        try:
            setup_context_user(info.context)
        except jwt.InvalidTokenError:
            return cls.handle_errors(
                ValidationError(
                    "Invalid token", code=AccountErrorCode.JWT_INVALID_TOKEN.value
                )
            )
        try:
            setup_context_user(info.context)
        except jwt.InvalidTokenError:
            return cls.handle_errors(
                ValidationError(
                    "Invalid token", code=AccountErrorCode.JWT_INVALID_TOKEN.value
                )
            )
from ....graphql.utils import INTERNAL_ERROR_MESSAGE
    assert content["errors"][0]["message"] == INTERNAL_ERROR_MESSAGE
from django.test import override_settings
from ..utils import ALLOWED_ERRORS, INTERNAL_ERROR_MESSAGE, format_error


@override_settings(DEBUG=False)
def test_format_error_hides_internal_error_msg_in_production_mode():
    error = ValueError("Example error")
    result = format_error(error, ())
    assert result["message"] == INTERNAL_ERROR_MESSAGE


@override_settings(DEBUG=False)
def test_format_error_prints_allowed_errors():
    error_cls = ALLOWED_ERRORS[0]
    error = error_cls("Example error")
    result = format_error(error, ())
    assert result["message"] == str(error)


@override_settings(DEBUG=True)
def test_format_error_prints_internal_error_msg_in_debug_mode():
    error = ValueError("Example error")
    result = format_error(error, ())
    assert result["message"] == str(error)
from django.core.exceptions import ValidationError
from ...core.exceptions import (
    CircularSubscriptionSyncEvent,
    PermissionDenied,
    ReadOnlyException,
)
from ..core.validators.query_cost import QueryCostError
# List of error types of which messages can be returned in the GraphQL API.
ALLOWED_ERRORS = [
    CircularSubscriptionSyncEvent,
    GraphQLError,
    PermissionDenied,
    ReadOnlyException,
    ValidationError,
    QueryCostError,
]

INTERNAL_ERROR_MESSAGE = "Internal Server Error"

    # If DEBUG mode is disabled we allow only certain error messages to be returned in
    # the API. This prevents from leaking internals that might be included in Python
    # exceptions' error messages.
    if type(exc) not in ALLOWED_ERRORS and not settings.DEBUG:
        result["message"] = INTERNAL_ERROR_MESSAGE