CVE-2023-39349: Improper Access Control (CWE-284)
value: Sentry is an error tracking and performance monitoring platform. Starting in version 22.1.0 and prior to version 23.7.2, an attacker with access to a token with few or no scopes can query `/api/0/api-tokens/` for a list of all tokens created by a user, including tokens with greater scopes, and use those tokens in other requests. There is no evidence that the issue was exploited on `sentry.io`. For self-hosted users, it is advised to rotate user auth tokens. A fix is available in version 23.7.2 of `sentry` and `self-hosted`. There are no known workarounds.
CWE description: The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor.
CVSS v3 base score: 8.1
Published: 2023-08-07T19:15Z
Repository: https://github.com/getsentry/sentry
Language: Python
Files changed: src/sentry/api/authentication.py, src/sentry/api/endpoints/api_tokens.py, tests/sentry/api/endpoints/test_api_tokens.py
Commit date: 2023-07-31 19:04:52 +0200
Commit message: SessionNoAuthTokenAuthentication custom authenticator (#53850)

<!-- Describe your PR here. -->



<!--

  Sentry employees and contractors can delete or ignore the following.

-->

### Legal Boilerplate

Look, I get it. The entity doing business as "Sentry" was incorporated
in the State of Delaware in 2015 as Functional Software, Inc. and is
gonna need some rights from me in order to utilize my contributions in
this here PR. So here's the deal: I retain all rights, title and
interest in and to my contributions, and by keeping this boilerplate
intact I confirm that Sentry can use, modify, copy, and redistribute my
contributions, under Sentry's choice of terms.
Diff: diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py
index 30b034b724b..c560e027919 100644
--- a/src/sentry/api/authentication.py
+++ b/src/sentry/api/authentication.py
@@ -2,11 +2,15 @@ from typing import List, Optional, Tuple
 
 from django.conf import settings
 from django.contrib.auth.models import AnonymousUser
 from django.utils.crypto import constant_time_compare
 from django.utils.encoding import force_str
-from rest_framework.authentication import BasicAuthentication, get_authorization_header
+from rest_framework.authentication import (
+    BasicAuthentication,
+    SessionAuthentication,
+    get_authorization_header,
+)
 from rest_framework.exceptions import AuthenticationFailed
 from rest_framework.request import Request
 from sentry_relay import UnpackError
 
 from sentry import options
@@ -155,10 +159,18 @@ class ApiKeyAuthentication(QuietBasicAuthentication):
             scope.set_tag("api_key", key.id)
 
         return (AnonymousUser(), key)
 
 
+class SessionNoAuthTokenAuthentication(SessionAuthentication):
+    def authenticate(self, request: Request):
+        auth = get_authorization_header(request)
+        if auth:
+            return None
+        return super().authenticate(request)
+
+
 class ClientIdSecretAuthentication(QuietBasicAuthentication):
     """
     Authenticates a Sentry Application using its Client ID and Secret
 
     This will be the method by which we identify which Sentry Application is
diff --git a/src/sentry/api/endpoints/api_tokens.py b/src/sentry/api/endpoints/api_tokens.py
index e7a08374da1..807cb128e24 100644
--- a/src/sentry/api/endpoints/api_tokens.py
+++ b/src/sentry/api/endpoints/api_tokens.py
@@ -1,15 +1,15 @@
 from django.conf import settings
 from django.utils.decorators import method_decorator
 from django.views.decorators.cache import never_cache
 from rest_framework import serializers
-from rest_framework.authentication import SessionAuthentication
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.request import Request
 from rest_framework.response import Response
 
 from sentry import analytics
+from sentry.api.authentication import SessionNoAuthTokenAuthentication
 from sentry.api.base import Endpoint, control_silo_endpoint
 from sentry.api.fields import MultipleChoiceField
 from sentry.api.serializers import serialize
 from sentry.auth.superuser import is_active_superuser
 from sentry.models import ApiToken
@@ -20,11 +20,11 @@ class ApiTokenSerializer(serializers.Serializer):
     scopes = MultipleChoiceField(required=True, choices=settings.SENTRY_SCOPES)
 
 
 @control_silo_endpoint
 class ApiTokensEndpoint(Endpoint):
-    authentication_classes = (SessionAuthentication,)
+    authentication_classes = (SessionNoAuthTokenAuthentication,)
     permission_classes = (IsAuthenticated,)
 
     @method_decorator(never_cache)
     def get(self, request: Request) -> Response:
         user_id = request.user.id
diff --git a/tests/sentry/api/endpoints/test_api_tokens.py b/tests/sentry/api/endpoints/test_api_tokens.py
index 0ee8a3a542e..ef02f1dbdbe 100644
--- a/tests/sentry/api/endpoints/test_api_tokens.py
+++ b/tests/sentry/api/endpoints/test_api_tokens.py
@@ -29,10 +29,17 @@ class ApiTokensListTest(APITestCase):
         assert (
             response.get("cache-control")
             == "max-age=0, no-cache, no-store, must-revalidate, private"
         )
 
+    def test_deny_token_access(self):
+        token = ApiToken.objects.create(user=self.user, scope_list=[])
+
+        url = reverse("sentry-api-0-api-tokens")
+        response = self.client.get(url, format="json", HTTP_AUTHORIZATION=f"Bearer {token.token}")
+        assert response.status_code == 403, response.content
+
 
 @control_silo_test(stable=True)
 class ApiTokensCreateTest(APITestCase):
     def test_no_scopes(self):
         self.login_as(self.user)

Vulnerable code: from rest_framework.authentication import BasicAuthentication, get_authorization_header
from rest_framework.authentication import SessionAuthentication
    authentication_classes = (SessionAuthentication,)
Fixed code: from rest_framework.authentication import (
    BasicAuthentication,
    SessionAuthentication,
    get_authorization_header,
)
class SessionNoAuthTokenAuthentication(SessionAuthentication):
    def authenticate(self, request: Request):
        auth = get_authorization_header(request)
        if auth:
            return None
        return super().authenticate(request)


from sentry.api.authentication import SessionNoAuthTokenAuthentication
    authentication_classes = (SessionNoAuthTokenAuthentication,)
    def test_deny_token_access(self):
        token = ApiToken.objects.create(user=self.user, scope_list=[])

        url = reverse("sentry-api-0-api-tokens")
        response = self.client.get(url, format="json", HTTP_AUTHORIZATION=f"Bearer {token.token}")
        assert response.status_code == 403, response.content