Skip to content

Commit 848bc12

Browse files
committed
feat(security): Add locking to prevent race conditions in API grant exchanges
- Add InvalidGrantError and ExpiredGrantError - Add get_lock_key method to ApiGrant - Add locking around grant exchanges in ApiToken.from_grant - Add tests for race conditions
1 parent 81d6c8e commit 848bc12

File tree

3 files changed

+58
-12
lines changed

3 files changed

+58
-12
lines changed

src/sentry/models/apigrant.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
DEFAULT_EXPIRATION = timedelta(minutes=10)
1616

1717

18+
class InvalidGrantError(Exception):
19+
pass
20+
21+
22+
class ExpiredGrantError(Exception):
23+
pass
24+
25+
1826
def default_expiration():
1927
return timezone.now() + DEFAULT_EXPIRATION
2028

@@ -97,6 +105,10 @@ def is_expired(self):
97105
def redirect_uri_allowed(self, uri):
98106
return uri == self.redirect_uri
99107

108+
@classmethod
109+
def get_lock_key(cls, grant_id):
110+
return f"api_grant:{grant_id}"
111+
100112
@classmethod
101113
def sanitize_relocation_json(
102114
cls, json: Any, sanitizer: Sanitizer, model_name: NormalizedModelName | None = None

src/sentry/models/apitoken.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
2020
from sentry.hybridcloud.outbox.base import ControlOutboxProducingManager, ReplicatedControlModel
2121
from sentry.hybridcloud.outbox.category import OutboxCategory
22-
from sentry.models.apigrant import ApiGrant
22+
from sentry.locks import locks
23+
from sentry.models.apiapplication import ApiApplicationStatus
24+
from sentry.models.apigrant import ApiGrant, ExpiredGrantError, InvalidGrantError
2325
from sentry.models.apiscopes import HasApiScopes
2426
from sentry.types.region import find_all_region_names
2527
from sentry.types.token import AuthTokenType
@@ -261,17 +263,33 @@ def handle_async_replication(self, region_name: str, shard_identifier: int) -> N
261263

262264
@classmethod
263265
def from_grant(cls, grant: ApiGrant):
264-
with transaction.atomic(router.db_for_write(cls)):
265-
api_token = cls.objects.create(
266-
application=grant.application,
267-
user=grant.user,
268-
scope_list=grant.get_scopes(),
269-
scoping_organization_id=grant.organization_id,
270-
)
271-
272-
# remove the ApiGrant from the database to prevent reuse of the same
273-
# authorization code
274-
grant.delete()
266+
if grant.application.status != ApiApplicationStatus.active:
267+
raise InvalidGrantError()
268+
269+
if grant.is_expired():
270+
raise ExpiredGrantError()
271+
272+
lock = locks.get(
273+
ApiGrant.get_lock_key(grant.id),
274+
duration=10,
275+
name="api_grant",
276+
)
277+
278+
# we use a lock to prevent race conditions when creating the ApiToken
279+
# an attacker could send two requests to create an access/refresh token pair
280+
# at the same time, using the same grant, and get two different tokens
281+
with lock.acquire():
282+
with transaction.atomic(router.db_for_write(cls)):
283+
api_token = cls.objects.create(
284+
application=grant.application,
285+
user=grant.user,
286+
scope_list=grant.get_scopes(),
287+
scoping_organization_id=grant.organization_id,
288+
)
289+
290+
# remove the ApiGrant from the database to prevent reuse of the same
291+
# authorization code
292+
grant.delete()
275293

276294
return api_token
277295

tests/sentry/sentry_apps/token_exchange/test_grant_exchanger.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ def test_deletes_grant_on_successful_exchange(self):
104104
self.grant_exchanger.run()
105105
assert not ApiGrant.objects.filter(id=grant_id)
106106

107+
def test_race_condition_on_grant_exchange(self):
108+
from sentry.locks import locks
109+
from sentry.utils.locking import UnableToAcquireLock
110+
111+
# simulate a race condition on the grant exchange
112+
grant_id = self.orm_install.api_grant_id
113+
lock = locks.get(
114+
ApiGrant.get_lock_key(grant_id),
115+
duration=10,
116+
name="api_grant",
117+
)
118+
lock.acquire()
119+
120+
with pytest.raises(UnableToAcquireLock):
121+
self.grant_exchanger.run()
122+
107123
@patch("sentry.analytics.record")
108124
def test_records_analytics(self, record):
109125
GrantExchanger(

0 commit comments

Comments
 (0)