From 9b15dce1b29491e059f033e71115fdf7428d051c Mon Sep 17 00:00:00 2001 From: Aman Agrawal Date: Wed, 18 Jun 2025 19:38:48 +0530 Subject: [PATCH] auth: Only automatically redirect for same domain redirects. If the `deactivated_redirect` belongs to the same domain as `EXTERNAL_HOST`, automatically redirect, otherwise just point user to the new URL. --- corporate/views/support.py | 1 + templates/zerver/deactivated.html | 17 +++++++++++++++-- web/src/portico/signup.ts | 15 +++++++++++++++ zerver/lib/subdomains.py | 13 +++++++++---- zerver/tests/test_signup.py | 17 +++++++++++++---- zerver/views/auth.py | 27 ++++++++++++++++++--------- 6 files changed, 71 insertions(+), 19 deletions(-) diff --git a/corporate/views/support.py b/corporate/views/support.py index e64a0d6762..7bd5b2b6d3 100644 --- a/corporate/views/support.py +++ b/corporate/views/support.py @@ -605,6 +605,7 @@ def support( if parse_result.port: hostname = f"{hostname}:{parse_result.port}" subdomain = get_subdomain_from_hostname(hostname) + assert subdomain is not None with suppress(Realm.DoesNotExist): realms.add(get_realm(subdomain)) except ValidationError: diff --git a/templates/zerver/deactivated.html b/templates/zerver/deactivated.html index 1bc3c511fb..6ffc2555f5 100644 --- a/templates/zerver/deactivated.html +++ b/templates/zerver/deactivated.html @@ -16,12 +16,25 @@
-

{{ _("Deactivated organization") }}

+ {% if deactivated_redirect %} +

{{ _("Organization moved") }}

+ {% else %} +

{{ _("Deactivated organization") }}

+ {% endif %}

- {% if realm_data_deleted %} + {% if deactivated_redirect %} + {% trans %} + This organization has moved to {{ deactivated_redirect }}. + {% endtrans %} + {% if auto_redirect_to %} + {% trans %} + This page will automatically redirect to the new URL in 5 seconds. + {% endtrans %} + {% endif %} + {% elif realm_data_deleted %} {{ _("This organization has been deactivated, and all organization data has been deleted.") }} {% if corporate_enabled %} {% trans %} diff --git a/web/src/portico/signup.ts b/web/src/portico/signup.ts index c798e9e877..a7a2fe5b7c 100644 --- a/web/src/portico/signup.ts +++ b/web/src/portico/signup.ts @@ -493,4 +493,19 @@ $(() => { $("#slack-access-token").on("input", () => { $("#update-slack-access-token").show(); }); + + if ($("a#deactivated-org-auto-redirect").length > 0) { + // This is a special case for the deactivated organization page, + // where we want to redirect to the login page after 5 seconds. + const interval_id = setInterval(() => { + const $countdown_elt = $("#deactivated-org-auto-redirect-countdown"); + const current_countdown = Number($countdown_elt.text()); + if (current_countdown > 0) { + $countdown_elt.text((current_countdown - 1).toString()); + } else { + window.location.href = $("a#deactivated-org-auto-redirect").attr("href")!; + clearInterval(interval_id); + } + }, 1000); + } }); diff --git a/zerver/lib/subdomains.py b/zerver/lib/subdomains.py index 745984b347..57aaad4843 100644 --- a/zerver/lib/subdomains.py +++ b/zerver/lib/subdomains.py @@ -22,22 +22,27 @@ def get_subdomain(request: HttpRequest) -> str: # compatibility with older versions of Zulip, so that's a start. host = request.get_host().lower() - return get_subdomain_from_hostname(host) + subdomain = get_subdomain_from_hostname(host) + assert subdomain is not None + return subdomain -def get_subdomain_from_hostname(host: str) -> str: +def get_subdomain_from_hostname( + host: str, default_subdomain: str | None = Realm.SUBDOMAIN_FOR_ROOT_DOMAIN +) -> str | None: + # Set `default_subdomain` as None to check if a valid subdomain was found. m = re.search(rf"\.{settings.EXTERNAL_HOST}(:\d+)?$", host) if m: subdomain = host[: m.start()] if subdomain in settings.ROOT_SUBDOMAIN_ALIASES: - return Realm.SUBDOMAIN_FOR_ROOT_DOMAIN + return default_subdomain return subdomain for subdomain, realm_host in settings.REALM_HOSTS.items(): if re.search(rf"^{realm_host}(:\d+)?$", host): return subdomain - return Realm.SUBDOMAIN_FOR_ROOT_DOMAIN + return default_subdomain def is_subdomain_root_or_alias(request: HttpRequest) -> bool: diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py index ead9fcf8fd..2bdc030dfb 100644 --- a/zerver/tests/test_signup.py +++ b/zerver/tests/test_signup.py @@ -191,14 +191,19 @@ class DeactivationNoticeTestCase(ZulipTestCase): realm.save(update_fields=["deactivated", "deactivated_redirect"]) result = self.client_get("/login/", follow=True) - self.assertIn(result.request.get("SERVER_NAME"), ["example.zulipchat.com"]) + self.assert_in_success_response( + ['href="http://example.zulipchat.com/" id="deactivated-org-auto-redirect"'], result + ) def test_deactivation_notice_when_realm_subdomain_is_changed(self) -> None: realm = get_realm("zulip") do_change_realm_subdomain(realm, "new-subdomain-name", acting_user=None) result = self.client_get("/login/", follow=True) - self.assertIn(result.request.get("SERVER_NAME"), ["new-subdomain-name.testserver"]) + self.assert_in_success_response( + ['href="http://new-subdomain-name.testserver/" id="deactivated-org-auto-redirect"'], + result, + ) def test_no_deactivation_notice_with_no_redirect(self) -> None: realm = get_realm("zulip") @@ -220,12 +225,16 @@ class DeactivationNoticeTestCase(ZulipTestCase): do_change_realm_subdomain(realm, "new-name-1", acting_user=None) result = self.client_get("/login/", follow=True) - self.assertIn(result.request.get("SERVER_NAME"), ["new-name-1.testserver"]) + self.assert_in_success_response( + ['href="http://new-name-1.testserver/" id="deactivated-org-auto-redirect"'], result + ) realm = get_realm("new-name-1") do_change_realm_subdomain(realm, "new-name-2", acting_user=None) result = self.client_get("/login/", follow=True) - self.assertIn(result.request.get("SERVER_NAME"), ["new-name-2.testserver"]) + self.assert_in_success_response( + ['href="http://new-name-2.testserver/" id="deactivated-org-auto-redirect"'], result + ) def test_deactivation_notice_when_deactivated_and_scrubbed(self) -> None: # We expect system bot messages when scrubbing a realm. diff --git a/zerver/views/auth.py b/zerver/views/auth.py index e203303e1e..2d11c1c0a5 100644 --- a/zerver/views/auth.py +++ b/zerver/views/auth.py @@ -3,7 +3,7 @@ import secrets from collections.abc import Callable, Mapping from functools import wraps from typing import TYPE_CHECKING, Any, Concatenate, TypeAlias, cast -from urllib.parse import urlencode, urljoin +from urllib.parse import urlencode, urljoin, urlsplit import jwt import orjson @@ -63,7 +63,11 @@ from zerver.lib.realm_icon import realm_icon_url from zerver.lib.request import RequestNotes from zerver.lib.response import json_success from zerver.lib.sessions import set_expirable_session_var -from zerver.lib.subdomains import get_subdomain, is_subdomain_root_or_alias +from zerver.lib.subdomains import ( + get_subdomain, + get_subdomain_from_hostname, + is_subdomain_root_or_alias, +) from zerver.lib.typed_endpoint import typed_endpoint from zerver.lib.url_encoding import append_url_query_string from zerver.lib.user_agent import parse_user_agent @@ -820,16 +824,21 @@ def redirect_to_misconfigured_ldap_notice(request: HttpRequest, error_type: int) def show_deactivation_notice(request: HttpRequest, next: str = "/") -> HttpResponse: realm = get_realm_from_request(request) if realm and realm.deactivated: - if realm.deactivated_redirect is not None: - # URL hash is automatically preserved by the browser. - # See https://stackoverflow.com/a/5283739 - redirect_to = get_safe_redirect_to(next, realm.deactivated_redirect) - return HttpResponseRedirect(redirect_to) - realm_data_scrubbed = RealmAuditLog.objects.filter( realm=realm, event_type=AuditLogEventType.REALM_SCRUBBED ).exists() - context = {"realm_data_deleted": realm_data_scrubbed} + context = { + "realm_data_deleted": realm_data_scrubbed, + "deactivated_redirect": realm.deactivated_redirect, + } + + if realm.deactivated_redirect is not None: + split = urlsplit(realm.deactivated_redirect) + host = f"{split.scheme}://{split.netloc}" + # If the redirect is in the same domain, do an automatic redirect. + if get_subdomain_from_hostname(host) is not None: + redirect_to = get_safe_redirect_to(next, realm.deactivated_redirect) + context["auto_redirect_to"] = redirect_to return render(request, "zerver/deactivated.html", context=context) return HttpResponseRedirect(reverse("login_page"))