CVE-2023-0440 (published 2023-01-23T14:15Z)

Observable Discrepancy in GitHub repository healthchecks/healthchecks prior to v2.6.

CVSS v3 base score: 5.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-203
cwe_name: Observable Discrepancy
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.
extended_description: Discrepancies can take many forms, and variations may be detectable in timing, control flow, communications such as replies or requests, or general behavior. These discrepancies can reveal information about the product's operation or internal state to an unauthorized actor. In some cases, discrepancies can be used by attackers to form a side channel.
         

Fix information:
repo_url: https://github.com/healthchecks/healthchecks
commit_details: repo_url: https://github.com/healthchecks/healthchecks
author: Pēteris Caune
author_date: 2023-01-23 12:33:39+02:00
msg: Fix login and signup views to make email enumeration harder
repository: repo_url: https://github.com/healthchecks/healthchecks
repo_name: healthchecks/healthchecks
description: Open-source cron job and background task monitoring service, written in Python & Django
repo_language: Python
owner: healthchecks
file_changes: filename: CHANGELOG.md
change_type: ModificationType.MODIFY
diff: @@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file.
 - Fix special character encoding in project invite emails
 - Fix check transfer between same account's projects when at check limit
 - Fix wording in the invite email when inviting read-only users
+- Fix login and signup views to make email enumeration harder
 
 ## v2.5 - 2022-12-14
 

programming_language: Markdown
filename: forms.py
change_type: ModificationType.MODIFY
diff: @@ -30,11 +30,6 @@ class SignupForm(forms.Form):
         if len(v) > 254:
             raise forms.ValidationError("Address is too long.")
 
-        if User.objects.filter(email=v).exists():
-            raise forms.ValidationError(
-                "An account with this email address already exists."
-            )
-
         return v
 
     def clean_tz(self):
@@ -60,7 +55,7 @@ class EmailLoginForm(forms.Form):
         try:
             self.user = User.objects.get(email=v)
         except User.DoesNotExist:
-            raise forms.ValidationError("Unknown email address.")
+            self.user = None
 
         return v
 

programming_language: Python
filename: test_login.py
change_type: ModificationType.MODIFY
diff: @@ -57,6 +57,16 @@ class LoginTestCase(BaseTestCase):
         body = mail.outbox[0].body
         self.assertTrue("/?next=" + self.channels_url in body)
 
+    def test_it_handles_unknown_email(self):
+        form = {"identity": "surprise@example.org"}
+
+        r = self.client.post("/accounts/login/", form)
+        self.assertRedirects(r, "/accounts/login_link_sent/")
+        self.assertIn("auto-login", r.cookies)
+
+        # There should be no sent emails.
+        self.assertEqual(len(mail.outbox), 0)
+
     @override_settings(SECRET_KEY="test-secret")
     def test_it_rate_limits_emails(self):
         # "d60d..." is sha1("alice@example.orgtest-secret")

programming_language: Python
filename: test_signup.py
change_type: ModificationType.MODIFY
diff: @@ -16,7 +16,7 @@ class SignupTestCase(TestCase):
         form = {"identity": "alice@example.org", "tz": "Europe/Riga"}
 
         r = self.client.post("/accounts/signup/", form)
-        self.assertContains(r, "Account created")
+        self.assertContains(r, "check your email")
         self.assertIn("auto-login", r.cookies)
 
         # An user should have been created
@@ -75,13 +75,17 @@ class SignupTestCase(TestCase):
         q = User.objects.filter(email="alice@example.org")
         self.assertTrue(q.exists)
 
-    def test_it_checks_for_existing_users(self):
+    def test_it_handles_existing_users(self):
         alice = User(username="alice", email="alice@example.org")
         alice.save()
 
         form = {"identity": "alice@example.org", "tz": ""}
         r = self.client.post("/accounts/signup/", form)
-        self.assertContains(r, "already exists")
+        self.assertContains(r, "check your email")
+        self.assertIn("auto-login", r.cookies)
+
+        # It should not send an email
+        self.assertEqual(len(mail.outbox), 0)
 
     def test_it_checks_syntax(self):
         form = {"identity": "alice at example org", "tz": ""}
@@ -101,7 +105,7 @@ class SignupTestCase(TestCase):
         form = {"identity": "alice@example.org", "tz": "Foo/Bar"}
 
         r = self.client.post("/accounts/signup/", form)
-        self.assertContains(r, "Account created")
+        self.assertContains(r, "check your email")
         self.assertIn("auto-login", r.cookies)
 
         profile = Profile.objects.get()

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -161,10 +161,11 @@ def login(request):
                 if not _allow_redirect(redirect_url):
                     redirect_url = None
 
-                profile = Profile.objects.for_user(magic_form.user)
-                profile.send_instant_login_link(redirect_url=redirect_url)
-                response = redirect("hc-login-link-sent")
+                if magic_form.user:
+                    profile = Profile.objects.for_user(magic_form.user)
+                    profile.send_instant_login_link(redirect_url=redirect_url)
 
+                response = redirect("hc-login-link-sent")
                 # check_token looks for this cookie to decide if
                 # it needs to do the extra POST step.
                 response.set_cookie("auto-login", "1", max_age=300, httponly=True)
@@ -201,16 +202,16 @@ def signup(request):
     form = forms.SignupForm(request.POST)
     if form.is_valid():
         email = form.cleaned_data["identity"]
-        tz = form.cleaned_data["tz"]
-        user = _make_user(email, tz)
-        profile = Profile.objects.for_user(user)
-        profile.send_instant_login_link()
-        ctx["created"] = True
+        if not User.objects.filter(email=email).exists():
+            tz = form.cleaned_data["tz"]
+            user = _make_user(email, tz)
+            profile = Profile.objects.for_user(user)
+            profile.send_instant_login_link()
     else:
         ctx = {"form": form}
 
     response = render(request, "accounts/signup_result.html", ctx)
-    if ctx.get("created"):
+    if "form" not in ctx:
         response.set_cookie("auto-login", "1", max_age=300, httponly=True)
 
     return response

programming_language: Python
filename: login_link_sent.html
change_type: ModificationType.MODIFY
diff: @@ -1,16 +1,16 @@
 {% extends "base.html" %}
+{% load hc_extras %}
 
 {% block content %}
 <div class="row">
     <div class="col-sm-6 col-sm-offset-3">
         <div class="hc-dialog">
-            <h1>Login Link Sent!</h1>
+            <h1>Check Your Email!</h1>
             <br />
             <p>
-                We've sent you an email with login instructions.
-                Please check your inbox!
+                If a {% site_name %} account exists for this email address,
+                you will receive a login link in your email shortly.
             </p>
-
         </div>
     </div>
 </div>

programming_language: HTML
filename: signup_result.html
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,5 @@
 {% for error in form.identity.errors %}
 <p class="text-danger">{{ error }}</p>
-{% endfor %}
-
-{% if created %}
-<p class="text-success">Account created, please check your email!</p>
-{% endif %}
\ No newline at end of file
+{% empty %}
+<p class="text-success">Please check your email!</p>
+{% endfor %}
\ No newline at end of file

programming_language: Markdown