Skip to content

Commit ad342ae

Browse files
feanilclaude
andcommitted
fix: remove activation_key from account REST API response
The activation_key field was exposed in /api/user/v1/accounts/{username}, allowing an attacker to bypass email verification by combining two behaviors: 1. OAuth2 password grant issues tokens to inactive users (intentional) 2. activation_key returned in API response (the vulnerability) An attacker could register, get an OAuth2 token, read the activation_key from the API, then GET /activate/{key} to activate without email access. Fix: remove activation_key from UserReadOnlySerializer.to_representation() and from ACCOUNT_VISIBILITY_CONFIGURATION["admin_fields"] (which controls the field whitelist in _filter_fields — listed fields default to None even if absent from the serializer data dict). Reported by Daniel Baillo via the Open edX security working group. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 6d0d910 commit ad342ae

5 files changed

Lines changed: 10 additions & 19 deletions

File tree

lms/envs/common.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2473,7 +2473,6 @@
24732473
"secondary_email_enabled",
24742474
"year_of_birth",
24752475
"phone_number",
2476-
"activation_key",
24772476
"pending_name_change",
24782477
]
24792478
)

openedx/core/djangoapps/user_api/accounts/serializers.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,6 @@ def to_representation(self, user): # lint-amnesty, pylint: disable=arguments-di
142142
except ObjectDoesNotExist:
143143
account_recovery = None
144144

145-
try:
146-
activation_key = user.registration.activation_key
147-
except ObjectDoesNotExist:
148-
activation_key = None
149-
150145
data = {
151146
"username": user.username,
152147
"url": self.context.get('request').build_absolute_uri(
@@ -161,7 +156,6 @@ def to_representation(self, user): # lint-amnesty, pylint: disable=arguments-di
161156
"date_joined": user.date_joined.replace(microsecond=0),
162157
"last_login": user.last_login,
163158
"is_active": user.is_active,
164-
"activation_key": activation_key,
165159
"bio": None,
166160
"country": None,
167161
"state": None,

openedx/core/djangoapps/user_api/accounts/tests/test_api.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,6 @@ def test_create_account(self):
634634
'id': user.id,
635635
'name': self.USERNAME,
636636
'verified_name': None,
637-
'activation_key': user.registration.activation_key,
638637
'gender': None, 'goals': '',
639638
'is_active': False,
640639
'level_of_education': None,

openedx/core/djangoapps/user_api/accounts/tests/test_views.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -361,8 +361,8 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
361361
"""
362362

363363
ENABLED_CACHES = ['default']
364-
TOTAL_QUERY_COUNT = 26
365-
FULL_RESPONSE_FIELD_COUNT = 29
364+
TOTAL_QUERY_COUNT = 25
365+
FULL_RESPONSE_FIELD_COUNT = 28
366366

367367
def setUp(self):
368368
super().setUp()
@@ -492,19 +492,19 @@ def test_get_account_unknown_user(self, api_client, user):
492492
("client", "user"),
493493
)
494494
@ddt.unpack
495-
def test_regsitration_activation_key(self, api_client, user):
495+
def test_regsitration_activation_key_not_exposed(self, api_client, user):
496496
"""
497-
Test that registration activation key has a value.
497+
Test that activation_key is NOT returned in the account API response.
498498
499-
UserFactory does not auto-generate registration object for the test users.
500-
It is created only for users that signup via email/API. Therefore, activation key has to be tested manually.
499+
The activation_key is a secret used for email verification and must not be
500+
exposed via the API, as doing so allows bypassing email verification.
501501
"""
502502
self.create_user_registration(self.user)
503503

504504
client = self.login_client(api_client, user)
505505
response = self.send_get(client)
506506

507-
assert response.data["activation_key"] is not None
507+
assert "activation_key" not in response.data
508508

509509
def test_successful_get_account_by_email(self):
510510
"""
@@ -815,12 +815,12 @@ def verify_get_own_information(queries):
815815
assert data['time_zone'] is None
816816

817817
self.client.login(username=self.user.username, password=TEST_PASSWORD)
818-
verify_get_own_information(self._get_num_queries(24))
818+
verify_get_own_information(self._get_num_queries(23))
819819

820820
# Now make sure that the user can get the same information, even if not active
821821
self.user.is_active = False
822822
self.user.save()
823-
verify_get_own_information(self._get_num_queries(16))
823+
verify_get_own_information(self._get_num_queries(15))
824824

825825
def test_get_account_empty_string(self):
826826
"""
@@ -835,7 +835,7 @@ def test_get_account_empty_string(self):
835835
legacy_profile.save()
836836

837837
self.client.login(username=self.user.username, password=TEST_PASSWORD)
838-
with self.assertNumQueries(self._get_num_queries(24), table_ignorelist=WAFFLE_TABLES):
838+
with self.assertNumQueries(self._get_num_queries(23), table_ignorelist=WAFFLE_TABLES):
839839
response = self.send_get(self.client)
840840
for empty_field in ("level_of_education", "gender", "country", "state", "bio",):
841841
assert response.data[empty_field] is None

openedx/core/djangoapps/user_api/accounts/views.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,6 @@ def retrieve(self, request, username):
293293
If the user makes the request for her own account, or makes a request for another account and has "is_staff" access, an HTTP 200 "OK" response is returned. The response contains the following values.
294294
295295
* `id`: numerical lms user id in db
296-
* `activation_key`: auto-genrated activation key when signed up via email
297296
* `bio`: null or textual representation of user biographical information ("about me").
298297
* `country`: An ISO 3166 country code or null.
299298
* `date_joined`: The date the account was created, in the string format provided by datetime. For example, "2014-08-26T17:52:11Z".

0 commit comments

Comments
 (0)