CVE-2023-39522: Observable Discrepancy (CWE-203)
value: goauthentik is an open-source Identity Provider. In affected versions using a recovery flow with an identification stage an attacker is able to determine if a username exists. Only setups configured with a recovery flow are impacted by this. Anyone with a user account on a system with the recovery flow described above is susceptible to having their username/email revealed as existing. An attacker can easily enumerate and check users' existence using the recovery flow, as a clear message is shown when a user doesn't exist. Depending on configuration this can either be done by username, email, or both. This issue has been addressed in versions 2023.5.6 and 2023.6.2. Users are advised to upgrade. There are no known workarounds for this issue.
CWE description: The product behaves differently or sends different responses under different circumstances in a way that is observable to an unauthorized actor, which exposes security-relevant information about the state of the product, such as whether a particular operation was successful or not.
CVSS v3 base score: 5.3
Published: 2023-08-29T18:15Z
Repository: https://github.com/goauthentik/authentik
Language: Python
Files changed: authentik/stages/email/stage.py, authentik/stages/email/tests/test_sending.py, authentik/stages/identification/stage.py, authentik/stages/identification/tests.py, website/docs/releases/2023/v2023.5.md, website/docs/releases/2023/v2023.6.md, website/docs/security/CVE-2023-39522.md, website/sidebars.js
Commit date: 2023-08-29 19:07:49 +0200
Commit message: security: fix CVE-2023-39522 (#6665)

* stages/email: don't disclose whether a user exists or not when recovering

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update website

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Diff: diff --git a/authentik/stages/email/stage.py b/authentik/stages/email/stage.py
index f116f7de0..a28fb3f39 100644
--- a/authentik/stages/email/stage.py
+++ b/authentik/stages/email/stage.py
@@ -10,11 +10,11 @@ from django.utils.timezone import now
 from django.utils.translation import gettext as _
 from rest_framework.fields import CharField
 from rest_framework.serializers import ValidationError
 
 from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
-from authentik.flows.models import FlowToken
+from authentik.flows.models import FlowDesignation, FlowToken
 from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
 from authentik.flows.stage import ChallengeStageView
 from authentik.flows.views.executor import QS_KEY_TOKEN
 from authentik.stages.email.models import EmailStage
 from authentik.stages.email.tasks import send_mails
@@ -80,10 +80,15 @@ class EmailStageView(ChallengeStageView):
 
     def send_email(self):
         """Helper function that sends the actual email. Implies that you've
         already checked that there is a pending user."""
         pending_user = self.get_pending_user()
+        if not pending_user.pk and self.executor.flow.designation == FlowDesignation.RECOVERY:
+            # Pending user does not have a primary key, and we're in a recovery flow,
+            # which means the user entered an invalid identifier, so we pretend to send the
+            # email, to not disclose if the user exists
+            return
         email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None)
         if not email:
             email = pending_user.email
         current_stage: EmailStage = self.executor.current_stage
         token = self.get_token()
diff --git a/authentik/stages/email/tests/test_sending.py b/authentik/stages/email/tests/test_sending.py
index b1c2aea15..424d474ce 100644
--- a/authentik/stages/email/tests/test_sending.py
+++ b/authentik/stages/email/tests/test_sending.py
@@ -3,22 +3,24 @@ from smtplib import SMTPException
 from unittest.mock import MagicMock, PropertyMock, patch
 
 from django.core import mail
 from django.core.mail.backends.locmem import EmailBackend
 from django.urls import reverse
-from rest_framework.test import APITestCase
 
+from authentik.core.models import User
 from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 from authentik.events.models import Event, EventAction
 from authentik.flows.markers import StageMarker
 from authentik.flows.models import FlowDesignation, FlowStageBinding
 from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.tests import FlowTestCase
 from authentik.flows.views.executor import SESSION_KEY_PLAN
+from authentik.lib.generators import generate_id
 from authentik.stages.email.models import EmailStage
 
 
-class TestEmailStageSending(APITestCase):
+class TestEmailStageSending(FlowTestCase):
     """Email tests"""
 
     def setUp(self):
         super().setUp()
         self.user = create_test_admin_user()
@@ -42,20 +44,53 @@ class TestEmailStageSending(APITestCase):
             "authentik.stages.email.models.EmailStage.backend_class",
             PropertyMock(return_value=EmailBackend),
         ):
             response = self.client.post(url)
             self.assertEqual(response.status_code, 200)
+            self.assertStageResponse(
+                response,
+                self.flow,
+                response_errors={
+                    "non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
+                },
+            )
             self.assertEqual(len(mail.outbox), 1)
             self.assertEqual(mail.outbox[0].subject, "authentik")
             events = Event.objects.filter(action=EventAction.EMAIL_SENT)
             self.assertEqual(len(events), 1)
             event = events.first()
             self.assertEqual(event.context["message"], f"Email to {self.user.email} sent")
             self.assertEqual(event.context["subject"], "authentik")
             self.assertEqual(event.context["to_email"], [self.user.email])
             self.assertEqual(event.context["from_email"], "system@authentik.local")
 
+    def test_pending_fake_user(self):
+        """Test with pending (fake) user"""
+        self.flow.designation = FlowDesignation.RECOVERY
+        self.flow.save()
+        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+        plan.context[PLAN_CONTEXT_PENDING_USER] = User(username=generate_id())
+        session = self.client.session
+        session[SESSION_KEY_PLAN] = plan
+        session.save()
+
+        url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
+        with patch(
+            "authentik.stages.email.models.EmailStage.backend_class",
+            PropertyMock(return_value=EmailBackend),
+        ):
+            response = self.client.post(url)
+            self.assertEqual(response.status_code, 200)
+            self.assertStageResponse(
+                response,
+                self.flow,
+                response_errors={
+                    "non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
+                },
+            )
+            self.assertEqual(len(mail.outbox), 0)
+
     def test_send_error(self):
         """Test error during sending (sending will be retried)"""
         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
         session = self.client.session
diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py
index a54de07d9..3a6a5bd25 100644
--- a/authentik/stages/identification/stage.py
+++ b/authentik/stages/identification/stage.py
@@ -116,12 +116,16 @@ class IdentificationChallengeResponse(ChallengeResponse):
             # policies can be applied.
             self.stage.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User(
                 username=uid_field,
                 email=uid_field,
             )
+            self.pre_user = self.stage.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
             if not current_stage.show_matched_user:
                 self.stage.executor.plan.context[PLAN_CONTEXT_PENDING_USER_IDENTIFIER] = uid_field
+            if self.stage.executor.flow.designation == FlowDesignation.RECOVERY:
+                # When used in a recovery flow, always continue to not disclose if a user exists
+                return attrs
             raise ValidationError("Failed to authenticate.")
         self.pre_user = pre_user
         if not current_stage.password_stage:
             # No password stage select, don't validate the password
             return attrs
diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py
index cdd7bf0e9..dabdea050 100644
--- a/authentik/stages/identification/tests.py
+++ b/authentik/stages/identification/tests.py
@@ -186,11 +186,11 @@ class TestIdentificationStage(FlowTestCase):
                     },
                 }
             ],
         )
 
-    def test_recovery_flow(self):
+    def test_link_recovery_flow(self):
         """Test that recovery flow is linked correctly"""
         flow = create_test_flow()
         self.stage.recovery_flow = flow
         self.stage.save()
         FlowStageBinding.objects.create(
@@ -224,10 +224,42 @@ class TestIdentificationStage(FlowTestCase):
                     "name": "test",
                 }
             ],
         )
 
+    def test_recovery_flow_invalid_user(self):
+        """Test that an invalid user can proceed in a recovery flow"""
+        self.flow.designation = FlowDesignation.RECOVERY
+        self.flow.save()
+        response = self.client.get(
+            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+        )
+        self.assertStageResponse(
+            response,
+            self.flow,
+            component="ak-stage-identification",
+            user_fields=["email"],
+            password_fields=False,
+            show_source_labels=False,
+            primary_action="Continue",
+            sources=[
+                {
+                    "challenge": {
+                        "component": "xak-flow-redirect",
+                        "to": "/source/oauth/login/test/",
+                        "type": ChallengeTypes.REDIRECT.value,
+                    },
+                    "icon_url": "/static/authentik/sources/default.svg",
+                    "name": "test",
+                }
+            ],
+        )
+        form_data = {"uid_field": generate_id()}
+        url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
+        response = self.client.post(url, form_data)
+        self.assertEqual(response.status_code, 200)
+
     def test_api_validate(self):
         """Test API validation"""
         self.assertTrue(
             IdentificationStageSerializer(
                 data={
diff --git a/website/docs/releases/2023/v2023.5.md b/website/docs/releases/2023/v2023.5.md
index f879b205e..5ec1d2f68 100644
--- a/website/docs/releases/2023/v2023.5.md
+++ b/website/docs/releases/2023/v2023.5.md
@@ -150,10 +150,14 @@ image:
 
 ## Fixed in 2023.5.5
 
 -   \*: fix [CVE-2023-36456](../security/CVE-2023-36456), Reported by [@thijsa](https://github.com/thijsa)
 
+## Fixed in 2023.5.6
+
+-   \*: fix [CVE-2023-39522](../security/CVE-2023-39522), Reported by [@markrassamni](https://github.com/markrassamni)
+
 ## API Changes
 
 #### What's Changed
 
 ---
diff --git a/website/docs/releases/2023/v2023.6.md b/website/docs/releases/2023/v2023.6.md
index a81ade848..7032044d0 100644
--- a/website/docs/releases/2023/v2023.6.md
+++ b/website/docs/releases/2023/v2023.6.md
@@ -86,10 +86,14 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2023.6
 -   outposts/ldap: revert attribute filtering (#6188)
 -   outposts/ldap: add test for attribute filtering (#6189)
 -   sources/ldap: fix more errors (#6191)
 -   sources/ldap: fix page size (#6187)
 
+## Fixed in 2023.6.2
+
+-   \*: fix [CVE-2023-39522](../security/CVE-2023-39522), Reported by [@markrassamni](https://github.com/markrassamni)
+
 ## API Changes
 
 #### What's New
 
 ---
diff --git a/website/docs/security/CVE-2023-39522.md b/website/docs/security/CVE-2023-39522.md
new file mode 100644
index 000000000..18f09b134
--- /dev/null
+++ b/website/docs/security/CVE-2023-39522.md
@@ -0,0 +1,27 @@
+# CVE-2023-39522
+
+_Reported by [@markrassamni](https://github.com/markrassamni)_
+
+## Username enumeration attack
+
+### Summary
+
+Using a recovery flow with an identification stage an attacker is able to determine if a username exists.
+
+### Patches
+
+authentik 2023.5.6 and 2023.6.2 fix this issue.
+
+### Impact
+
+Only setups configured with a recovery flow are impacted by this.
+
+### Details
+
+An attacker can easily enumerate and check users' existence using the recovery flow, as a clear message is shown when a user doesn't exist. Depending on configuration this can either be done by username, email, or both.
+
+### For more information
+
+If you have any questions or comments about this advisory:
+
+-   Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
diff --git a/website/sidebars.js b/website/sidebars.js
index 79a8886fe..01f84ba55 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -359,10 +359,11 @@ const docsSidebar = {
                 title: "Security",
                 slug: "security",
             },
             items: [
                 "security/policy",
+                "security/CVE-2023-39522",
                 "security/CVE-2023-36456",
                 "security/2023-06-cure53",
                 "security/CVE-2023-26481",
                 "security/CVE-2022-23555",
                 "security/CVE-2022-46145",

Vulnerable code: from authentik.flows.models import FlowToken
from rest_framework.test import APITestCase
class TestEmailStageSending(APITestCase):
    def test_recovery_flow(self):
Fixed code: from authentik.flows.models import FlowDesignation, FlowToken
        if not pending_user.pk and self.executor.flow.designation == FlowDesignation.RECOVERY:
            # Pending user does not have a primary key, and we're in a recovery flow,
            # which means the user entered an invalid identifier, so we pretend to send the
            # email, to not disclose if the user exists
            return
from authentik.core.models import User
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
class TestEmailStageSending(FlowTestCase):
            self.assertStageResponse(
                response,
                self.flow,
                response_errors={
                    "non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
                },
            )
    def test_pending_fake_user(self):
        """Test with pending (fake) user"""
        self.flow.designation = FlowDesignation.RECOVERY
        self.flow.save()
        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
        plan.context[PLAN_CONTEXT_PENDING_USER] = User(username=generate_id())
        session = self.client.session
        session[SESSION_KEY_PLAN] = plan
        session.save()

        url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
        with patch(
            "authentik.stages.email.models.EmailStage.backend_class",
            PropertyMock(return_value=EmailBackend),
        ):
            response = self.client.post(url)
            self.assertEqual(response.status_code, 200)
            self.assertStageResponse(
                response,
                self.flow,
                response_errors={
                    "non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
                },
            )
            self.assertEqual(len(mail.outbox), 0)

            self.pre_user = self.stage.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
            if self.stage.executor.flow.designation == FlowDesignation.RECOVERY:
                # When used in a recovery flow, always continue to not disclose if a user exists
                return attrs
    def test_link_recovery_flow(self):
    def test_recovery_flow_invalid_user(self):
        """Test that an invalid user can proceed in a recovery flow"""
        self.flow.designation = FlowDesignation.RECOVERY
        self.flow.save()
        response = self.client.get(
            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
        )
        self.assertStageResponse(
            response,
            self.flow,
            component="ak-stage-identification",
            user_fields=["email"],
            password_fields=False,
            show_source_labels=False,
            primary_action="Continue",
            sources=[
                {
                    "challenge": {
                        "component": "xak-flow-redirect",
                        "to": "/source/oauth/login/test/",
                        "type": ChallengeTypes.REDIRECT.value,
                    },
                    "icon_url": "/static/authentik/sources/default.svg",
                    "name": "test",
                }
            ],
        )
        form_data = {"uid_field": generate_id()}
        url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
        response = self.client.post(url, form_data)
        self.assertEqual(response.status_code, 200)

## Fixed in 2023.5.6

-   \*: fix [CVE-2023-39522](../security/CVE-2023-39522), Reported by [@markrassamni](https://github.com/markrassamni)

## Fixed in 2023.6.2

-   \*: fix [CVE-2023-39522](../security/CVE-2023-39522), Reported by [@markrassamni](https://github.com/markrassamni)

# CVE-2023-39522

_Reported by [@markrassamni](https://github.com/markrassamni)_

## Username enumeration attack

### Summary

Using a recovery flow with an identification stage an attacker is able to determine if a username exists.

### Patches

authentik 2023.5.6 and 2023.6.2 fix this issue.

### Impact

Only setups configured with a recovery flow are impacted by this.

### Details

An attacker can easily enumerate and check users' existence using the recovery flow, as a clear message is shown when a user doesn't exist. Depending on configuration this can either be done by username, email, or both.

### For more information

If you have any questions or comments about this advisory:

-   Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
                "security/CVE-2023-39522",