Medium severity5.3OSV Advisory· Published Sep 9, 2025· Updated Apr 15, 2026
CVE-2025-58442
CVE-2025-58442
Description
Saleor is an e-commerce platform. Starting in version 3.21.0 and prior to version 3.21.16, requesting certain fields in the response of accountRegister may result in errors that could unintentionally reveal whether a user with the provided email already exists in Saleor. Version 3.21.16 fixes the issue. As a workaround, rate-limit the mutation to reduce the impact.
Affected products
1Patches
209d671e91ea5Merge commit from fork
9 files changed · +156 −33
package.json+1 −1 modified@@ -1,6 +1,6 @@ { "name": "saleor", - "version": "3.21.15", + "version": "3.21.16", "engines": { "node": ">=16 <17", "npm": ">=7"
package-lock.json+2 −2 modified@@ -1,12 +1,12 @@ { "name": "saleor", - "version": "3.21.15", + "version": "3.21.16", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "saleor", - "version": "3.21.15", + "version": "3.21.16", "license": "BSD-3-Clause", "devDependencies": { "@release-it/bumper": "^4.0.0",
pyproject.toml+1 −1 modified@@ -53,7 +53,7 @@ help = "Run tests with db reuse to speed up testing time" [tool.poetry] name = "saleor" -version = "3.21.15" +version = "3.21.16" description = "A modular, high performance, headless e-commerce platform built with Python, GraphQL, Django, and React." authors = [ "Saleor Commerce <hello@saleor.io>" ] license = "BSD-3-Clause"
saleor/account/models.py+1 −1 modified@@ -191,7 +191,7 @@ class User( uuid = models.UUIDField(default=uuid4, unique=True) USERNAME_FIELD = "email" - RETURN_ID_IN_API_RESPONSE = True + NEWLY_CREATED_USER = False objects = UserManager()
saleor/graphql/account/mutations/account/account_register.py+3 −1 modified@@ -102,7 +102,7 @@ def mutate(cls, root, info: ResolveInfo, **data): ) # we don't want to return id's as it will allow to deduce if user exists if response.user: - response.user.RETURN_ID_IN_API_RESPONSE = False + response.user.NEWLY_CREATED_USER = True return response @classmethod @@ -187,6 +187,8 @@ def perform_mutation(cls, _root, info: ResolveInfo, /, **data): context_data = RequestorAwareContext.create_context_data(info.context) cls.save_and_create_task(user_exists, instance, cleaned_input, context_data) + # Sets updated_at, to always return the time when mutation was called + instance.updated_at = instance.date_joined return cls.success_response(instance) @classmethod
saleor/graphql/account/tests/mutations/account/test_account_register.py+86 −20 modified@@ -2,6 +2,8 @@ from urllib.parse import urlencode from django.test import override_settings +from django.utils import timezone +from freezegun import freeze_time from ......account import events as account_events from ......account.models import User @@ -16,25 +18,77 @@ from .....tests.utils import get_graphql_content ACCOUNT_REGISTER_MUTATION = """ - mutation RegisterAccount( - $input: AccountRegisterInput! - ) { - accountRegister( - input: $input - ) { - errors { - field - message - code - } - user { - id - email - firstName - lastName - } +mutation RegisterAccount($input: AccountRegisterInput!) { + accountRegister(input: $input) { + errors { + field + message + code + } + user { + id + email + firstName + lastName + addresses { + streetAddress1 + } + metadata { + key + } + metafield(key: "test") + metafields(keys: ["test1"]) + checkoutIds + checkouts(first: 10) { + edges { + node { + id + } + } + } + giftCards(first: 1) { + edges { + node { + id + } } + } + orders(first: 1) { + edges { + node { + id + } + } + } + userPermissions { + code + } + storedPaymentMethods(channel: "default-channel") { + id + } + dateJoined + lastLogin + externalReference + defaultBillingAddress { + id + } + defaultShippingAddress { + id + } + languageCode + storedPaymentSources { + __typename + } + avatar { + url + } + isStaff + isActive + isConfirmed + updatedAt } + } +} """ @@ -165,6 +219,8 @@ def test_customer_register_twice( data = content["data"][mutation_name] params = urlencode({"email": email, "token": "token"}) confirm_url = prepare_url(params, redirect_url) + new_user.last_login = timezone.now() + new_user.save() expected_payload = { "user": get_default_user_payload(new_user), @@ -201,18 +257,28 @@ def test_customer_register_twice( # remove personal data from input del variables["input"]["firstName"] del variables["input"]["lastName"] - response = api_client.post_graphql(query, variables) - content = get_graphql_content(response) - data = content["data"][mutation_name] + with freeze_time("2025-06-01 12:00:01"): + query_time = timezone.now() + response = api_client.post_graphql(query, variables) + content = get_graphql_content(response) + data = content["data"][mutation_name] # then assert not data["errors"] + new_user.refresh_from_db() + assert new_user.updated_at != query_time + assert new_user.date_joined != query_time + assert new_user.last_login is not None + customer_creation_event = account_events.CustomerEvent.objects.get() assert data["user"]["firstName"] == "" assert data["user"]["lastName"] == "" assert data["user"]["email"] == variables["input"]["email"] assert data["user"]["id"] == "" + assert data["user"]["updatedAt"] == query_time.isoformat() + assert data["user"]["dateJoined"] == query_time.isoformat() + assert data["user"]["lastLogin"] is None assert customer_creation_event.type == account_events.CustomerEvents.ACCOUNT_CREATED assert customer_creation_event.user == new_user assert mocked_finish_creating_user.delay.call_count == 2
saleor/graphql/account/types.py+60 −2 modified@@ -329,6 +329,19 @@ def resolve_source_permission_groups(root: Permission, info: ResolveInfo, user_i return groups +def is_newly_created_user( + user: models.User, +): + """Determine if the resolver is called for newly created user. + + Newly created user can be represented as user instance that is not stored in DB. + We need to skip any resolvers that requires existing id value. + """ + if getattr(user, "NEWLY_CREATED_USER", False): + return True + return False + + @federated_entity("id") @federated_entity("email") class User(ModelObjectType[models.User]): @@ -492,10 +505,14 @@ class Meta: @staticmethod def resolve_addresses(root: models.User, _info: ResolveInfo): + if is_newly_created_user(root): + return [] return root.addresses.annotate_default(root).all() @staticmethod def resolve_checkout(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] database_connection_name = get_database_connection_name(info.context) checkout = get_user_checkout( root, database_connection_name=database_connection_name @@ -507,6 +524,9 @@ def resolve_checkout(root: models.User, info: ResolveInfo): @staticmethod @traced_resolver def resolve_checkout_tokens(root: models.User, info: ResolveInfo, channel=None): + if is_newly_created_user(root): + return [] + def return_checkout_tokens(checkouts): if not checkouts: return [] @@ -530,6 +550,9 @@ def return_checkout_tokens(checkouts): @staticmethod @traced_resolver def resolve_checkout_ids(root: models.User, info: ResolveInfo, channel=None): + if is_newly_created_user(root): + return [] + def return_checkout_ids(checkouts): if not checkouts: return [] @@ -561,6 +584,9 @@ def _resolve_checkouts(checkouts): allow_sync_webhooks=False, ) + if is_newly_created_user(root): + return _resolve_checkouts([]) + if channel := kwargs.get("channel"): return ( CheckoutByUserAndChannelLoader(info.context) @@ -578,33 +604,46 @@ def _resolve_gift_cards(gift_cards): gift_cards, info, kwargs, GiftCardCountableConnection ) + if is_newly_created_user(root): + return _resolve_gift_cards([]) + return ( GiftCardsByUserLoader(info.context).load(root.id).then(_resolve_gift_cards) ) @staticmethod def resolve_user_permissions(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] from .resolvers import resolve_permissions return resolve_permissions(root, info) @staticmethod def resolve_permission_groups(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] return root.groups.using(get_database_connection_name(info.context)).all() @staticmethod def resolve_editable_groups(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] database_connection_name = get_database_connection_name(info.context) return get_groups_which_user_can_manage(root, database_connection_name) @staticmethod - def resolve_accessible_channels(root: models.Group, info: ResolveInfo): + def resolve_accessible_channels(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] # Sum of channels from all user groups. If at least one group has # `restrictedAccessToChannels` set to False - all channels are returned return AccessibleChannelsByUserIdLoader(info.context).load(root.id) @staticmethod - def resolve_restricted_access_to_channels(root: models.Group, info: ResolveInfo): + def resolve_restricted_access_to_channels(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return False # Returns False if at least one user group has `restrictedAccessToChannels` # set to False return RestrictedChannelAccessByUserIdLoader(info.context).load(root.id) @@ -615,12 +654,23 @@ def resolve_note(root: models.User, _info: ResolveInfo): @staticmethod def resolve_events(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] return CustomerEventsByUserLoader(info.context).load(root.id) @staticmethod def resolve_orders(root: models.User, info: ResolveInfo, **kwargs): from ..order.types import OrderCountableConnection + if is_newly_created_user(root): + return create_connection_slice_for_sync_webhook_control_context( + [], + info, + kwargs, + OrderCountableConnection, + allow_sync_webhooks=False, + ) + user_or_app = get_user_or_app_from_context(info.context) if not user_or_app or ( root != user_or_app @@ -699,6 +749,8 @@ def _resolve_avatar(thumbnail): def resolve_stored_payment_sources( root: models.User, info: ResolveInfo, channel=None ): + if is_newly_created_user(root): + return [] from .resolvers import resolve_payment_sources if root == info.context.user: @@ -743,6 +795,8 @@ def resolve_stored_payment_methods( info: ResolveInfo, channel: str, ): + if is_newly_created_user(root): + return [] requestor = get_user_or_app_from_context(info.context) if not requestor or requestor.id != root.id: return [] @@ -764,12 +818,16 @@ def get_stored_payment_methods(data: tuple[Channel, "PluginsManager"]): @staticmethod def resolve_default_billing_address(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return None if root.default_billing_address_id: return AddressByIdLoader(info.context).load(root.default_billing_address_id) return None @staticmethod def resolve_default_shipping_address(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return None if root.default_shipping_address_id: return AddressByIdLoader(info.context).load( root.default_shipping_address_id
saleor/graphql/core/types/common.py+1 −4 modified@@ -117,10 +117,7 @@ def __init__(self, of_type, *args, **kwargs): class SecureGlobalID(graphene.GlobalID): @staticmethod def id_resolver(parent_resolver, node, root, info, parent_type_name=None, **args): - if ( - hasattr(root, "RETURN_ID_IN_API_RESPONSE") - and not root.RETURN_ID_IN_API_RESPONSE - ): + if hasattr(root, "NEWLY_CREATED_USER") and root.NEWLY_CREATED_USER: return "" return graphene.GlobalID.id_resolver( parent_resolver, node, root, info, parent_type_name, **args
saleor/__init__.py+1 −1 modified@@ -3,7 +3,7 @@ from .celeryconf import app as celery_app __all__ = ["celery_app"] -__version__ = "3.21.15" +__version__ = "3.21.16" class PatchedSubscriberExecutionContext:
b35783838e51Merge commit from fork
5 files changed · +151 −28
saleor/account/models.py+1 −1 modified@@ -198,7 +198,7 @@ class User( number_of_orders = models.PositiveIntegerField(default=0, db_default=0) USERNAME_FIELD = "email" - RETURN_ID_IN_API_RESPONSE = True + NEWLY_CREATED_USER = False objects = UserManager()
saleor/graphql/account/mutations/account/account_register.py+3 −1 modified@@ -102,7 +102,7 @@ def mutate(cls, root, info: ResolveInfo, **data): ) # we don't want to return id's as it will allow to deduce if user exists if response.user: - response.user.RETURN_ID_IN_API_RESPONSE = False + response.user.NEWLY_CREATED_USER = True return response @classmethod @@ -187,6 +187,8 @@ def perform_mutation(cls, _root, info: ResolveInfo, /, **data): context_data = RequestorAwareContext.create_context_data(info.context) cls.save_and_create_task(user_exists, instance, cleaned_input, context_data) + # Sets updated_at, to always return the time when mutation was called + instance.updated_at = instance.date_joined return cls.success_response(instance) @classmethod
saleor/graphql/account/tests/mutations/account/test_account_register.py+86 −20 modified@@ -2,6 +2,8 @@ from urllib.parse import urlencode from django.test import override_settings +from django.utils import timezone +from freezegun import freeze_time from ......account import events as account_events from ......account.models import User @@ -16,25 +18,77 @@ from .....tests.utils import get_graphql_content ACCOUNT_REGISTER_MUTATION = """ - mutation RegisterAccount( - $input: AccountRegisterInput! - ) { - accountRegister( - input: $input - ) { - errors { - field - message - code - } - user { - id - email - firstName - lastName - } +mutation RegisterAccount($input: AccountRegisterInput!) { + accountRegister(input: $input) { + errors { + field + message + code + } + user { + id + email + firstName + lastName + addresses { + streetAddress1 + } + metadata { + key + } + metafield(key: "test") + metafields(keys: ["test1"]) + checkoutIds + checkouts(first: 10) { + edges { + node { + id + } + } + } + giftCards(first: 1) { + edges { + node { + id + } } + } + orders(first: 1) { + edges { + node { + id + } + } + } + userPermissions { + code + } + storedPaymentMethods(channel: "default-channel") { + id + } + dateJoined + lastLogin + externalReference + defaultBillingAddress { + id + } + defaultShippingAddress { + id + } + languageCode + storedPaymentSources { + __typename + } + avatar { + url + } + isStaff + isActive + isConfirmed + updatedAt } + } +} """ @@ -165,6 +219,8 @@ def test_customer_register_twice( data = content["data"][mutation_name] params = urlencode({"email": email, "token": "token"}) confirm_url = prepare_url(params, redirect_url) + new_user.last_login = timezone.now() + new_user.save() expected_payload = { "user": get_default_user_payload(new_user), @@ -201,18 +257,28 @@ def test_customer_register_twice( # remove personal data from input del variables["input"]["firstName"] del variables["input"]["lastName"] - response = api_client.post_graphql(query, variables) - content = get_graphql_content(response) - data = content["data"][mutation_name] + with freeze_time("2025-06-01 12:00:01"): + query_time = timezone.now() + response = api_client.post_graphql(query, variables) + content = get_graphql_content(response) + data = content["data"][mutation_name] # then assert not data["errors"] + new_user.refresh_from_db() + assert new_user.updated_at != query_time + assert new_user.date_joined != query_time + assert new_user.last_login is not None + customer_creation_event = account_events.CustomerEvent.objects.get() assert data["user"]["firstName"] == "" assert data["user"]["lastName"] == "" assert data["user"]["email"] == variables["input"]["email"] assert data["user"]["id"] == "" + assert data["user"]["updatedAt"] == query_time.isoformat() + assert data["user"]["dateJoined"] == query_time.isoformat() + assert data["user"]["lastLogin"] is None assert customer_creation_event.type == account_events.CustomerEvents.ACCOUNT_CREATED assert customer_creation_event.user == new_user assert mocked_finish_creating_user.delay.call_count == 2
saleor/graphql/account/types.py+60 −2 modified@@ -329,6 +329,19 @@ def resolve_source_permission_groups(root: Permission, info: ResolveInfo, user_i return groups +def is_newly_created_user( + user: models.User, +): + """Determine if the resolver is called for newly created user. + + Newly created user can be represented as user instance that is not stored in DB. + We need to skip any resolvers that requires existing id value. + """ + if getattr(user, "NEWLY_CREATED_USER", False): + return True + return False + + @federated_entity("id") @federated_entity("email") class User(ModelObjectType[models.User]): @@ -492,10 +505,14 @@ class Meta: @staticmethod def resolve_addresses(root: models.User, _info: ResolveInfo): + if is_newly_created_user(root): + return [] return root.addresses.annotate_default(root).all() @staticmethod def resolve_checkout(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] database_connection_name = get_database_connection_name(info.context) checkout = get_user_checkout( root, database_connection_name=database_connection_name @@ -507,6 +524,9 @@ def resolve_checkout(root: models.User, info: ResolveInfo): @staticmethod @traced_resolver def resolve_checkout_tokens(root: models.User, info: ResolveInfo, channel=None): + if is_newly_created_user(root): + return [] + def return_checkout_tokens(checkouts): if not checkouts: return [] @@ -530,6 +550,9 @@ def return_checkout_tokens(checkouts): @staticmethod @traced_resolver def resolve_checkout_ids(root: models.User, info: ResolveInfo, channel=None): + if is_newly_created_user(root): + return [] + def return_checkout_ids(checkouts): if not checkouts: return [] @@ -561,6 +584,9 @@ def _resolve_checkouts(checkouts): allow_sync_webhooks=False, ) + if is_newly_created_user(root): + return _resolve_checkouts([]) + if channel := kwargs.get("channel"): return ( CheckoutByUserAndChannelLoader(info.context) @@ -578,33 +604,46 @@ def _resolve_gift_cards(gift_cards): gift_cards, info, kwargs, GiftCardCountableConnection ) + if is_newly_created_user(root): + return _resolve_gift_cards([]) + return ( GiftCardsByUserLoader(info.context).load(root.id).then(_resolve_gift_cards) ) @staticmethod def resolve_user_permissions(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] from .resolvers import resolve_permissions return resolve_permissions(root, info) @staticmethod def resolve_permission_groups(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] return root.groups.using(get_database_connection_name(info.context)).all() @staticmethod def resolve_editable_groups(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] database_connection_name = get_database_connection_name(info.context) return get_groups_which_user_can_manage(root, database_connection_name) @staticmethod - def resolve_accessible_channels(root: models.Group, info: ResolveInfo): + def resolve_accessible_channels(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] # Sum of channels from all user groups. If at least one group has # `restrictedAccessToChannels` set to False - all channels are returned return AccessibleChannelsByUserIdLoader(info.context).load(root.id) @staticmethod - def resolve_restricted_access_to_channels(root: models.Group, info: ResolveInfo): + def resolve_restricted_access_to_channels(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return False # Returns False if at least one user group has `restrictedAccessToChannels` # set to False return RestrictedChannelAccessByUserIdLoader(info.context).load(root.id) @@ -615,12 +654,23 @@ def resolve_note(root: models.User, _info: ResolveInfo): @staticmethod def resolve_events(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return [] return CustomerEventsByUserLoader(info.context).load(root.id) @staticmethod def resolve_orders(root: models.User, info: ResolveInfo, **kwargs): from ..order.types import OrderCountableConnection + if is_newly_created_user(root): + return create_connection_slice_for_sync_webhook_control_context( + [], + info, + kwargs, + OrderCountableConnection, + allow_sync_webhooks=False, + ) + user_or_app = get_user_or_app_from_context(info.context) if not user_or_app or ( root != user_or_app @@ -699,6 +749,8 @@ def _resolve_avatar(thumbnail): def resolve_stored_payment_sources( root: models.User, info: ResolveInfo, channel=None ): + if is_newly_created_user(root): + return [] from .resolvers import resolve_payment_sources if root == info.context.user: @@ -743,6 +795,8 @@ def resolve_stored_payment_methods( info: ResolveInfo, channel: str, ): + if is_newly_created_user(root): + return [] requestor = get_user_or_app_from_context(info.context) if not requestor or requestor.id != root.id: return [] @@ -764,12 +818,16 @@ def get_stored_payment_methods(data: tuple[Channel, "PluginsManager"]): @staticmethod def resolve_default_billing_address(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return None if root.default_billing_address_id: return AddressByIdLoader(info.context).load(root.default_billing_address_id) return None @staticmethod def resolve_default_shipping_address(root: models.User, info: ResolveInfo): + if is_newly_created_user(root): + return None if root.default_shipping_address_id: return AddressByIdLoader(info.context).load( root.default_shipping_address_id
saleor/graphql/core/types/common.py+1 −4 modified@@ -117,10 +117,7 @@ def __init__(self, of_type, *args, **kwargs): class SecureGlobalID(graphene.GlobalID): @staticmethod def id_resolver(parent_resolver, node, root, info, parent_type_name=None, **args): - if ( - hasattr(root, "RETURN_ID_IN_API_RESPONSE") - and not root.RETURN_ID_IN_API_RESPONSE - ): + if hasattr(root, "NEWLY_CREATED_USER") and root.NEWLY_CREATED_USER: return "" return graphene.GlobalID.id_resolver( parent_resolver, node, root, info, parent_type_name, **args
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.