CVE-2022-21706: Other (NVD-CWE-Other)
value: 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).
CWE 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.
CVSS v3 base score: 9.8
Published: 2022-02-26T00:15Z
Repository: https://github.com/zulip/zulip
Language: Python
Files changed: zerver/tests/test_auth_backends.py, zerver/tests/test_signup.py, zerver/views/auth.py, zerver/views/registration.py
Commit date: 2022-02-25 14:02:24 -0800
Commit message: 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.

Diff: diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py
index a5764c848c2..fa38f83b806 100644
--- a/zerver/tests/test_auth_backends.py
+++ b/zerver/tests/test_auth_backends.py
@@ -89,10 +89,11 @@ from zerver.models import (
     MultiuseInvite,
     PasswordTooWeakError,
     PreregistrationUser,
     Realm,
     RealmDomain,
+    Stream,
     UserProfile,
     clear_supported_auth_backends_cache,
     email_to_username,
     get_realm,
     get_user_by_delivery_email,
@@ -1580,10 +1581,52 @@ class SocialAuthBase(DesktopFlowTestingLib, ZulipTestCase):
         )
         self.stage_two_of_registration(
             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"
         name = "Full Name"
         account_data_dict = self.get_account_data_dict(email=email, name=name)
diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py
index b4b7af82dfe..958e0823807 100644
--- a/zerver/tests/test_signup.py
+++ b/zerver/tests/test_signup.py
@@ -9,11 +9,11 @@ from urllib.parse import urlencode
 import orjson
 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
 
 from confirmation import settings as confirmation_settings
@@ -105,10 +105,11 @@ from zerver.models import (
     get_user_by_delivery_email,
 )
 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
 
 
 class RedirectAndLogIntoSubdomainTestCase(ZulipTestCase):
     def test_data(self) -> None:
@@ -2901,10 +2902,38 @@ class MultiuseInviteTest(ZulipTestCase):
         streams = [get_stream(stream_name, self.realm) for stream_name in stream_names]
         invite_link = self.generate_multiuse_invite_link(streams=streams)
         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")
 
         result = self.client_post("/json/invites/multiuse", {"invite_expires_in_days": 2})
         self.assert_json_success(result)
diff --git a/zerver/views/auth.py b/zerver/views/auth.py
index 0b968a91263..641a39fd4da 100644
--- a/zerver/views/auth.py
+++ b/zerver/views/auth.py
@@ -179,28 +179,32 @@ def maybe_send_to_registration(
     elif desktop_flow_otp:
         set_expirable_session_var(
             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
         try:
             multiuse_obj = get_object_from_key(multiuse_object_key, [Confirmation.MULTIUSE_INVITE])
         except ConfirmationKeyException as exception:
             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)
     if form.is_valid():
         # If the email address is allowed to sign up for an account in
diff --git a/zerver/views/registration.py b/zerver/views/registration.py
index e0b160980a9..e3ced98098b 100644
--- a/zerver/views/registration.py
+++ b/zerver/views/registration.py
@@ -674,11 +674,15 @@ def accounts_home(
     from_multiuse_invite = False
     streams_to_subscribe = None
     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
 
     if request.method == "POST":
@@ -724,16 +728,18 @@ def accounts_home(
     )
     return render(request, "zerver/accounts_home.html", context=context)
 
 
 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(
         request, multiuse_object_key=confirmation_key, multiuse_object=multiuse_object
     )

Vulnerable code: from django.http import HttpResponse
    realm: Optional[Realm] = None
        realm = multiuse_obj.realm
        try:
            realm = get_realm(get_subdomain(request))
        except Realm.DoesNotExist:
            pass
        realm = multiuse_object.realm
        realm = get_realm_from_request(request)
Fixed code:     Stream,
    @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
        )

from django.http import HttpRequest, HttpResponse
from zerver.views.registration import accounts_home
    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)

    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

        if realm != multiuse_obj.realm:
            return render(request, "confirmation/link_does_not_exist.html", status=404)

        # 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

    realm = get_realm_from_request(request)
        if realm != multiuse_object.realm:
            return render(request, "confirmation/link_does_not_exist.html", status=404)