CVE-2022-21706 (published 2022-02-26T00:15Z)

Zulip is an open-source team collaboration tool with topic-based threading. Zulip Server version 2.0.0 and above are vulnerable to insufficient access control with multi-use invitations. A Zulip Server deployment which hosts multiple organizations is vulnerable to an attack where an invitation created in one organization (potentially as a role with elevated permissions) can be used to join any other organization. This bypasses any restrictions on required domains on users' email addresses, may be used to gain access to organizations which are only accessible by invitation, and may be used to gain access with elevated privileges. This issue has been patched in release 4.10. There are no known workarounds for this issue. ### Patches _Has the problem been patched? What versions should users upgrade to?_ ### Workarounds _Is there a way for users to fix or remediate the vulnerability without upgrading?_ ### References _Are there any links users can visit to find out more?_ ### For more information If you have any questions or comments about this advisory, you can discuss them on the [developer community Zulip server](https://zulip.com/developer-community/), or email the [Zulip security team](mailto:security@zulip.com).

Severity: HIGH
CVSS v2 base score: 7.5 (AV:N/AC:L/Au:N/C:P/I:P/A:P)
CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: NVD-CWE-Other
cwe_name: Other
description: NVD is only using a subset of CWE for mapping instead of the entire CWE, and the weakness type is not covered by that subset.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/zulip/zulip
commit_details: repo_url: https://github.com/zulip/zulip
author: Mateusz Mandera
author_date: 2022-01-23 13:22:12+01:00
msg: CVE-2022-21706: Prevent use of multiuse invites to join other orgs.

The codepaths for joining an organization via a multi-use invitation
(accounts_home_from_multiuse_invite and maybe_send_to_registration)
weren't validating whether
the organization the invite was generated for matches the organization
the user attempts to join - potentially allowing an attacker with access
to organization A to generate a multi-use invite and use it to join
organization B within the same deployment, that they shouldn't have
access to.
repository: repo_url: https://github.com/zulip/zulip
repo_name: zulip/zulip
description: Zulip server and web application. Open-source team chat that helps teams stay productive and focused.
repo_language: Python
owner: zulip
file_changes: filename: test_auth_backends.py
change_type: ModificationType.MODIFY
diff: @@ -91,6 +91,7 @@ from zerver.models import (
     PreregistrationUser,
     Realm,
     RealmDomain,
+    Stream,
     UserProfile,
     clear_supported_auth_backends_cache,
     email_to_username,
@@ -1582,6 +1583,48 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase):
             result, realm, subdomain, email, name, name, self.BACKEND_CLASS.full_name_validated
         )
 
+    @override_settings(TERMS_OF_SERVICE_VERSION=None)
+    def test_social_auth_registration_using_multiuse_invite_realm_validation(self) -> None:
+        """If the user doesn't exist yet, social auth can be used to register an account"""
+        email = "newuser@zulip.com"
+        name = "Full Name"
+        subdomain = "zulip"
+        realm = get_realm("zulip")
+        realm.invite_required = True
+        realm.save()
+
+        streams: List[Stream] = []
+
+        # Generate an invitation for a different realm than the one we'll attempt to join:
+        lear_realm = get_realm("lear")
+        multiuse_obj = MultiuseInvite.objects.create(
+            realm=lear_realm, referred_by=UserProfile.objects.filter(realm=lear_realm).first()
+        )
+        multiuse_obj.streams.set(streams)
+        validity_in_days = 2
+        create_confirmation_link(
+            multiuse_obj, Confirmation.MULTIUSE_INVITE, validity_in_days=validity_in_days
+        )
+        multiuse_confirmation = Confirmation.objects.all().last()
+        assert multiuse_confirmation is not None
+        multiuse_object_key = multiuse_confirmation.confirmation_key
+        account_data_dict = self.get_account_data_dict(email=email, name=name)
+
+        # Now we try to use the invitation for the lear realm to join the zulip realm,
+        # which should fail.
+        result = self.social_auth_test(
+            account_data_dict,
+            subdomain=subdomain,
+            is_signup=True,
+            expect_choose_email_screen=True,
+            multiuse_object_key=multiuse_object_key,
+        )
+
+        result = self.client_get(result.url)
+        self.assert_in_response(
+            "Whoops. We couldn't find your confirmation link in the system.", result
+        )
+
     def test_social_auth_registration_without_is_signup(self) -> None:
         """If `is_signup` is not set then a new account isn't created"""
         email = "newuser@zulip.com"

programming_language: Python
filename: test_signup.py
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,7 @@ from django.conf import settings
 from django.contrib.auth.views import PasswordResetConfirmView
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
-from django.http import HttpResponse
+from django.http import HttpRequest, HttpResponse
 from django.test import Client, override_settings
 from django.urls import reverse
 from django.utils.timezone import now as timezone_now
@@ -107,6 +107,7 @@ from zerver.models import (
 from zerver.views.auth import redirect_and_log_into_subdomain, start_two_factor_auth
 from zerver.views.development.registration import confirmation_key
 from zerver.views.invite import get_invitee_emails_set
+from zerver.views.registration import accounts_home
 from zproject.backends import ExternalAuthDataDict, ExternalAuthResult
 
 
@@ -2903,6 +2904,34 @@ class MultiuseInviteTest(ZulipTestCase):
         self.check_user_able_to_register(email2, invite_link)
         self.check_user_subscribed_only_to_streams(name2, streams)
 
+    def test_multiuse_link_different_realms(self) -> None:
+        """
+        Verify that an invitation generated for one realm can't be used
+        to join another.
+        """
+        lear_realm = get_realm("lear")
+        self.realm = lear_realm
+        invite_link = self.generate_multiuse_invite_link(streams=[])
+        key = invite_link.split("/")[-2]
+
+        result = self.client_get(f"/join/{key}/", subdomain="zulip")
+        self.assertEqual(result.status_code, 404)
+        self.assert_in_response(
+            "Whoops. We couldn't find your confirmation link in the system.", result
+        )
+
+        # Now we want to test the accounts_home function, which can't be used
+        # for the multiuse invite case via an HTTP request, but is still supposed
+        # to do its own verification that the realms match as a hardening measure
+        # against a caller that fails to do that.
+        request = HttpRequest()
+        confirmation = Confirmation.objects.get(confirmation_key=key)
+        multiuse_object = confirmation.content_object
+        with patch(
+            "zerver.views.registration.get_subdomain", return_value="zulip"
+        ), self.assertRaises(AssertionError):
+            accounts_home(request, multiuse_object=multiuse_object)
+
     def test_create_multiuse_link_api_call(self) -> None:
         self.login("iago")
 

programming_language: Python
filename: auth.py
change_type: ModificationType.MODIFY
diff: @@ -181,8 +181,14 @@ def maybe_send_to_registration(
             request.session, "registration_desktop_flow_otp", desktop_flow_otp, expiry_seconds=3600
         )
 
+    try:
+        # TODO: This should use get_realm_from_request, but a bunch of tests
+        # rely on mocking get_subdomain here, so they'll need to be tweaked first.
+        realm: Optional[Realm] = get_realm(get_subdomain(request))
+    except Realm.DoesNotExist:
+        realm = None
+
     multiuse_obj: Optional[MultiuseInvite] = None
-    realm: Optional[Realm] = None
     from_multiuse_invite = False
     if multiuse_object_key:
         from_multiuse_invite = True
@@ -192,13 +198,11 @@ def maybe_send_to_registration(
             return render_confirmation_key_error(request, exception)
 
         assert multiuse_obj is not None
-        realm = multiuse_obj.realm
+        if realm != multiuse_obj.realm:
+            return render(request, "confirmation/link_does_not_exist.html", status=404)
+
         invited_as = multiuse_obj.invited_as
     else:
-        try:
-            realm = get_realm(get_subdomain(request))
-        except Realm.DoesNotExist:
-            pass
         invited_as = PreregistrationUser.INVITE_AS["MEMBER"]
 
     form = HomepageForm({"email": email}, realm=realm, from_multiuse_invite=from_multiuse_invite)

programming_language: Python
filename: registration.py
change_type: ModificationType.MODIFY
diff: @@ -676,7 +676,11 @@ def accounts_home(
     invited_as = None
 
     if multiuse_object:
-        realm = multiuse_object.realm
+        # multiuse_object's realm should have been validated by the caller,
+        # so this code shouldn't be reachable with a multiuse_object which
+        # has its realm mismatching the realm of the request.
+        assert realm == multiuse_object.realm
+
         streams_to_subscribe = multiuse_object.streams.all()
         from_multiuse_invite = True
         invited_as = multiuse_object.invited_as
@@ -726,12 +730,14 @@ def accounts_home(
 
 
 def accounts_home_from_multiuse_invite(request: HttpRequest, confirmation_key: str) -> HttpResponse:
+    realm = get_realm_from_request(request)
     multiuse_object = None
     try:
         multiuse_object = get_object_from_key(confirmation_key, [Confirmation.MULTIUSE_INVITE])
+        if realm != multiuse_object.realm:
+            return render(request, "confirmation/link_does_not_exist.html", status=404)
         # Required for OAuth 2
     except ConfirmationKeyException as exception:
-        realm = get_realm_from_request(request)
         if realm is None or realm.invite_required:
             return render_confirmation_key_error(request, exception)
     return accounts_home(

programming_language: Python