From 0578990303dbc9045d54c7c53dffbc99103b3157 Mon Sep 17 00:00:00 2001 From: awais786 Date: Fri, 15 May 2026 18:39:53 +0500 Subject: [PATCH 1/3] feat(auth): add GET /auth/portal-sign-out/ for cross-app logout chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foss-server-bundle portal's "Log out of all apps" button needs to clear each app's session cookie in addition to the shared _oauth2_proxy cookie and the Cognito SSO session. Cross-origin Set-Cookie isn't a thing — only the app whose origin owns the cookie can clear it — so the portal has to navigate the browser through each app's domain. This endpoint is the Plane participant in that chain: GET /auth/portal-sign-out/?next= 1. logout(request) — flushes the Django session 2. Validates next against MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS (suffix match on a dot boundary; ".foss.arbisoft.com" matches subdomains but not "foss.arbisoft.com.evil") 3. 302 to next CSRF-exempt because the portal cannot obtain Plane's CSRF token cross- origin. The residual risk is force-logout (an attacker embeds and ends the victim's session). That's low-impact: the only state lost is the session itself, and re-auth via ForwardAuth is automatic on the next request. When ?next= is omitted or invalid, falls back to MPASS_SIGNOUT_URL (if set) so a direct hit to the endpoint still chains through oauth2-proxy + Cognito. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/.env.example | 3 + apps/api/plane/authentication/urls.py | 9 + .../plane/authentication/views/__init__.py | 1 + .../views/app/portal_signout.py | 86 ++++++++ apps/api/plane/settings/common.py | 10 + .../tests/unit/views/test_portal_signout.py | 194 ++++++++++++++++++ 6 files changed, 303 insertions(+) create mode 100644 apps/api/plane/authentication/views/app/portal_signout.py create mode 100644 apps/api/plane/tests/unit/views/test_portal_signout.py diff --git a/apps/api/.env.example b/apps/api/.env.example index 6bed24003b4..44fdca1bc06 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -79,3 +79,6 @@ API_KEY_RATE_LIMIT="60/minute" # MPASS_BYPASS_PATHS=/god-mode,/api/instances # Required for 3-layer logout (Django → oauth2-proxy → Cognito) # MPASS_SIGNOUT_URL=https://foss-auth.local.moneta.dev/oauth2/sign_out?rd=https%3A%2F%2Fcognito.example.com%2Flogout +# Allowlist for /auth/portal-sign-out/?next= redirect targets — comma-separated +# host suffixes. Used by the foss-bundle portal's "Log out of all apps" chain. +# MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS=foss.arbisoft.com,localhost diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index bc736087fcb..2552a1d3c85 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -6,6 +6,7 @@ from .views import ( CSRFTokenEndpoint, + PortalSignOutEndpoint, SignOutAuthEndpoint, SignOutAuthSpaceEndpoint, ) @@ -36,6 +37,14 @@ # signout — kept active (used by frontend 3-layer logout) path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"), path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"), + # portal-driven signout — GET-able, CSRF-exempt, used by the foss-bundle + # portal's "Log out of all apps" redirect chain to clear the Django + # session cookie while the browser is on this app's domain. + path( + "portal-sign-out/", + PortalSignOutEndpoint.as_view(), + name="portal-sign-out", + ), # csrf token — kept active (Django forms need it) path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index a9c816ae9ea..45639d62211 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -14,6 +14,7 @@ from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint from .app.signout import SignOutAuthEndpoint +from .app.portal_signout import PortalSignOutEndpoint from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint diff --git a/apps/api/plane/authentication/views/app/portal_signout.py b/apps/api/plane/authentication/views/app/portal_signout.py new file mode 100644 index 00000000000..b6d8e891de7 --- /dev/null +++ b/apps/api/plane/authentication/views/app/portal_signout.py @@ -0,0 +1,86 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from urllib.parse import urlparse + +from django.conf import settings +from django.contrib.auth import logout +from django.http import HttpResponseBadRequest, HttpResponseRedirect +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + + +@method_decorator(csrf_exempt, name="dispatch") +class PortalSignOutEndpoint(View): + """ + GET /auth/portal-sign-out/?next= + + Clears the Django session and 302-redirects the browser to ``next``. + + Designed for the foss-server-bundle portal's "Log out of all apps" + redirect chain — the portal can navigate the browser through each + app's portal-sign-out URL, each step clearing its own session cookie + while the browser is on that app's own domain (so the Set-Cookie + scope is correct). + + CSRF-exempt: no token is shared cross-origin with the portal, so the + POST + CSRF flow used by the in-app SignOutAuthEndpoint isn't usable + here. The residual risk is force-logout (an attacker embeds + ```` and the victim's session ends). + That's low impact (annoying, not destructive — the only state lost is + the session itself, and re-auth via ForwardAuth is automatic). + + The ``next`` URL is validated against + ``MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS`` to prevent this endpoint from + being weaponised as an open redirect. Each allowlist entry is a host + suffix; ``foss.arbisoft.com`` matches ``pm.foss.arbisoft.com``, + ``docs.foss.arbisoft.com``, etc., but does not match + ``foss.arbisoft.com.evil.example``. + """ + + def get(self, request): + logout(request) + + next_url = (request.GET.get("next") or "").strip() + + if next_url: + if not self._is_allowed_next(next_url): + return HttpResponseBadRequest( + "next= target host is not in MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS" + ) + return HttpResponseRedirect(next_url) + + # No next= supplied — fall back to MPASS_SIGNOUT_URL so a manual hit + # to this endpoint still chains through oauth2-proxy + Cognito. + fallback = getattr(settings, "MPASS_SIGNOUT_URL", "") or "/" + return HttpResponseRedirect(fallback) + + @staticmethod + def _is_allowed_next(url): + """True iff the URL's hostname matches an entry in the allowlist. + + Allowlist entries are matched as suffixes on a dot boundary: + ``foss.arbisoft.com`` matches ``foss.arbisoft.com`` and + ``*.foss.arbisoft.com``, but not ``foss.arbisoft.com.evil``. + """ + allowed = getattr(settings, "MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS", []) or [] + if not allowed: + return False + + try: + host = urlparse(url).hostname + except ValueError: + return False + if not host: + return False + + host = host.lower() + for entry in allowed: + entry = (entry or "").strip().lower().lstrip(".") + if not entry: + continue + if host == entry or host.endswith("." + entry): + return True + return False diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 6d23c23a8d7..11e80bb0c7d 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -62,6 +62,16 @@ # mPass proxy auth MPASS_BYPASS_PATHS = [p.strip() for p in os.environ.get("MPASS_BYPASS_PATHS", "").split(",") if p.strip()] or None DEFAULT_EMAIL_DOMAIN = os.environ.get("DEFAULT_EMAIL_DOMAIN", "askii.ai") +# Allowlist for the ?next= parameter on /auth/portal-sign-out/ — comma- +# separated host suffixes. Each entry matches its exact host plus all +# subdomains (e.g. "foss.arbisoft.com" matches "pm.foss.arbisoft.com"). +# Empty list disables the endpoint's redirect behaviour (any ?next= is +# rejected); the endpoint still flushes the Django session. +MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = [ + h.strip().lower().lstrip(".") + for h in os.environ.get("MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS", "").split(",") + if h.strip() +] # SMB portal hostname segment (landing / logout redirects) vs default Plane workspace slug SMB_NAME = os.environ.get("SMB_NAME") diff --git a/apps/api/plane/tests/unit/views/test_portal_signout.py b/apps/api/plane/tests/unit/views/test_portal_signout.py new file mode 100644 index 00000000000..1b8120eb06e --- /dev/null +++ b/apps/api/plane/tests/unit/views/test_portal_signout.py @@ -0,0 +1,194 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Unit tests for PortalSignOutEndpoint. + +SPEC (GIVEN / WHEN / THEN) +────────────────────────── + 1 GIVEN a valid ?next= URL on an allowlisted host + WHEN GET /auth/portal-sign-out/?next=… is called + THEN Django session is cleared and response 302s to that URL + + 2 GIVEN ?next= is omitted + WHEN GET /auth/portal-sign-out/ is called with MPASS_SIGNOUT_URL set + THEN response 302s to MPASS_SIGNOUT_URL (session still cleared) + + 3 GIVEN ?next= is omitted AND MPASS_SIGNOUT_URL is unset + WHEN GET /auth/portal-sign-out/ is called + THEN response 302s to "/" + + 4 GIVEN ?next= is on a host not in MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS + WHEN GET /auth/portal-sign-out/?next=https://evil.example/ is called + THEN response is 400 Bad Request — open-redirect protection + + 5 GIVEN allowlist contains "foss.arbisoft.com" + WHEN ?next= hostname is "foss.arbisoft.com.evil" + THEN response is 400 — suffix match enforces dot boundary + + 6 GIVEN allowlist contains "foss.arbisoft.com" + WHEN ?next= hostname is "docs.foss.arbisoft.com" (subdomain) + THEN response 302s to that URL — subdomain allowed + + 7 GIVEN allowlist is empty + WHEN any ?next= is supplied + THEN response is 400 — empty allowlist rejects every redirect + + 8 GIVEN ?next= contains a malformed URL + WHEN GET /auth/portal-sign-out/?next=:::garbage is called + THEN response is 400 — defensive; we don't redirect to junk +""" + +from unittest.mock import MagicMock, patch + +import pytest +from django.test import RequestFactory + +from plane.authentication.views.app.portal_signout import PortalSignOutEndpoint + +pytestmark = pytest.mark.unit + + +def _make_request(factory: RequestFactory, query: str = "") -> MagicMock: + path = "/auth/portal-sign-out/" + (f"?{query}" if query else "") + req = factory.get(path) + req.user = MagicMock(id="user-uuid-1234") + return req + + +@pytest.fixture +def factory(): + return RequestFactory() + + +@pytest.fixture +def view(): + return PortalSignOutEndpoint() + + +@pytest.mark.unit +class TestPortalSignOutEndpoint: + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_redirects_to_allowlisted_next( + self, mock_settings, mock_logout, factory, view + ): + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + next_url = "https://docs.foss.arbisoft.com/auth/portal-sign-out/" + + response = view.get(_make_request(factory, f"next={next_url}")) + + mock_logout.assert_called_once() + assert response.status_code == 302 + assert response["Location"] == next_url + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_falls_back_to_mpass_signout_url_when_no_next( + self, mock_settings, mock_logout, factory, view + ): + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.MPASS_SIGNOUT_URL = "https://auth.foss.arbisoft.com/oauth2/sign_out" + + response = view.get(_make_request(factory)) + + mock_logout.assert_called_once() + assert response.status_code == 302 + assert response["Location"] == "https://auth.foss.arbisoft.com/oauth2/sign_out" + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_falls_back_to_root_when_no_next_and_no_mpass_url( + self, mock_settings, mock_logout, factory, view + ): + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.MPASS_SIGNOUT_URL = "" + + response = view.get(_make_request(factory)) + + mock_logout.assert_called_once() + assert response.status_code == 302 + assert response["Location"] == "/" + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_rejects_next_on_disallowed_host( + self, mock_settings, mock_logout, factory, view + ): + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + + response = view.get( + _make_request(factory, "next=https://evil.example/steal") + ) + + mock_logout.assert_called_once() # session is still flushed + assert response.status_code == 400 + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_suffix_match_enforces_dot_boundary( + self, mock_settings, mock_logout, factory, view + ): + # "foss.arbisoft.com.evil" must not match the "foss.arbisoft.com" entry. + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + + response = view.get( + _make_request(factory, "next=https://foss.arbisoft.com.evil/x") + ) + + assert response.status_code == 400 + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_subdomain_matches_suffix_entry( + self, mock_settings, mock_logout, factory, view + ): + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + next_url = "https://pm.foss.arbisoft.com/portal/done" + + response = view.get(_make_request(factory, f"next={next_url}")) + + assert response.status_code == 302 + assert response["Location"] == next_url + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_empty_allowlist_rejects_all_next( + self, mock_settings, mock_logout, factory, view + ): + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = [] + + response = view.get( + _make_request(factory, "next=https://docs.foss.arbisoft.com/x") + ) + + assert response.status_code == 400 + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_malformed_next_is_rejected( + self, mock_settings, mock_logout, factory, view + ): + # Garbage URL: no hostname extractable → reject. + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + + response = view.get(_make_request(factory, "next=not-a-url")) + + assert response.status_code == 400 + + @patch("plane.authentication.views.app.portal_signout.logout") + @patch("plane.authentication.views.app.portal_signout.settings") + def test_allowlist_entry_with_leading_dot_is_normalised( + self, mock_settings, mock_logout, factory, view + ): + # Operators sometimes write ".foss.arbisoft.com" — that leading dot + # is stripped and the entry treated as a host suffix. + mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = [".foss.arbisoft.com"] + next_url = "https://pm.foss.arbisoft.com/done" + + response = view.get(_make_request(factory, f"next={next_url}")) + + assert response.status_code == 302 + assert response["Location"] == next_url From a68017f39436893540ad8f9f55979e2548c63ca9 Mon Sep 17 00:00:00 2001 From: awais786 Date: Mon, 18 May 2026 17:39:07 +0500 Subject: [PATCH 2/3] fix(auth): switch ?next= allowlist from MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS to PLATFORM_DOMAIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the bundle-wide PLATFORM_DOMAIN env var (set by foss-server-bundle/platform.sh) instead of inventing a per-app allowlist env. Same suffix-match semantics with dot-boundary enforcement; less surface area for operator misconfiguration. PLATFORM_DOMAIN unset → endpoint still flushes the Django session, just rejects every ?next=. Tests updated to mock the new attribute. --- .../views/app/portal_signout.py | 35 +++++++++---------- apps/api/plane/settings/common.py | 16 ++++----- .../tests/unit/views/test_portal_signout.py | 18 +++++----- 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/apps/api/plane/authentication/views/app/portal_signout.py b/apps/api/plane/authentication/views/app/portal_signout.py index b6d8e891de7..f6bd73e06aa 100644 --- a/apps/api/plane/authentication/views/app/portal_signout.py +++ b/apps/api/plane/authentication/views/app/portal_signout.py @@ -32,11 +32,11 @@ class PortalSignOutEndpoint(View): That's low impact (annoying, not destructive — the only state lost is the session itself, and re-auth via ForwardAuth is automatic). - The ``next`` URL is validated against - ``MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS`` to prevent this endpoint from - being weaponised as an open redirect. Each allowlist entry is a host - suffix; ``foss.arbisoft.com`` matches ``pm.foss.arbisoft.com``, - ``docs.foss.arbisoft.com``, etc., but does not match + The ``next`` URL is validated against ``PLATFORM_DOMAIN`` (set by + foss-server-bundle/platform.sh) to prevent this endpoint from being + weaponised as an open redirect. The URL's host must equal + ``PLATFORM_DOMAIN`` exactly or be a subdomain of it. Dot boundary + enforced: ``foss.arbisoft.com`` matches subdomains but NOT ``foss.arbisoft.com.evil.example``. """ @@ -48,7 +48,7 @@ def get(self, request): if next_url: if not self._is_allowed_next(next_url): return HttpResponseBadRequest( - "next= target host is not in MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS" + "next= target host is not a subdomain of PLATFORM_DOMAIN" ) return HttpResponseRedirect(next_url) @@ -59,14 +59,17 @@ def get(self, request): @staticmethod def _is_allowed_next(url): - """True iff the URL's hostname matches an entry in the allowlist. + """True iff the URL's host equals PLATFORM_DOMAIN or is a subdomain. - Allowlist entries are matched as suffixes on a dot boundary: - ``foss.arbisoft.com`` matches ``foss.arbisoft.com`` and - ``*.foss.arbisoft.com``, but not ``foss.arbisoft.com.evil``. + Suffix match enforces a dot boundary: ``foss.arbisoft.com`` + matches ``foss.arbisoft.com`` and ``*.foss.arbisoft.com``, but + not ``foss.arbisoft.com.evil``. Unset PLATFORM_DOMAIN → False + (every next= rejected). """ - allowed = getattr(settings, "MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS", []) or [] - if not allowed: + platform_domain = ( + getattr(settings, "PLATFORM_DOMAIN", "") or "" + ).strip().lower().lstrip(".") + if not platform_domain: return False try: @@ -77,10 +80,4 @@ def _is_allowed_next(url): return False host = host.lower() - for entry in allowed: - entry = (entry or "").strip().lower().lstrip(".") - if not entry: - continue - if host == entry or host.endswith("." + entry): - return True - return False + return host == platform_domain or host.endswith("." + platform_domain) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 11e80bb0c7d..d7c23634142 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -62,16 +62,12 @@ # mPass proxy auth MPASS_BYPASS_PATHS = [p.strip() for p in os.environ.get("MPASS_BYPASS_PATHS", "").split(",") if p.strip()] or None DEFAULT_EMAIL_DOMAIN = os.environ.get("DEFAULT_EMAIL_DOMAIN", "askii.ai") -# Allowlist for the ?next= parameter on /auth/portal-sign-out/ — comma- -# separated host suffixes. Each entry matches its exact host plus all -# subdomains (e.g. "foss.arbisoft.com" matches "pm.foss.arbisoft.com"). -# Empty list disables the endpoint's redirect behaviour (any ?next= is -# rejected); the endpoint still flushes the Django session. -MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = [ - h.strip().lower().lstrip(".") - for h in os.environ.get("MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS", "").split(",") - if h.strip() -] +# Root domain of the foss-server-bundle deployment, set by +# foss-server-bundle/platform.sh. All app subdomains hang off this. Used +# by /auth/portal-sign-out/ as the ?next= redirect allowlist: only URLs +# whose host equals PLATFORM_DOMAIN or is a subdomain of it are followed. +# Unset → endpoint still flushes the Django session, just won't redirect. +PLATFORM_DOMAIN = (os.environ.get("PLATFORM_DOMAIN", "") or "").strip().lower().lstrip(".") # SMB portal hostname segment (landing / logout redirects) vs default Plane workspace slug SMB_NAME = os.environ.get("SMB_NAME") diff --git a/apps/api/plane/tests/unit/views/test_portal_signout.py b/apps/api/plane/tests/unit/views/test_portal_signout.py index 1b8120eb06e..5e387f963e8 100644 --- a/apps/api/plane/tests/unit/views/test_portal_signout.py +++ b/apps/api/plane/tests/unit/views/test_portal_signout.py @@ -75,7 +75,7 @@ class TestPortalSignOutEndpoint: def test_redirects_to_allowlisted_next( self, mock_settings, mock_logout, factory, view ): - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" next_url = "https://docs.foss.arbisoft.com/auth/portal-sign-out/" response = view.get(_make_request(factory, f"next={next_url}")) @@ -89,7 +89,7 @@ def test_redirects_to_allowlisted_next( def test_falls_back_to_mpass_signout_url_when_no_next( self, mock_settings, mock_logout, factory, view ): - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" mock_settings.MPASS_SIGNOUT_URL = "https://auth.foss.arbisoft.com/oauth2/sign_out" response = view.get(_make_request(factory)) @@ -103,7 +103,7 @@ def test_falls_back_to_mpass_signout_url_when_no_next( def test_falls_back_to_root_when_no_next_and_no_mpass_url( self, mock_settings, mock_logout, factory, view ): - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" mock_settings.MPASS_SIGNOUT_URL = "" response = view.get(_make_request(factory)) @@ -117,7 +117,7 @@ def test_falls_back_to_root_when_no_next_and_no_mpass_url( def test_rejects_next_on_disallowed_host( self, mock_settings, mock_logout, factory, view ): - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" response = view.get( _make_request(factory, "next=https://evil.example/steal") @@ -132,7 +132,7 @@ def test_suffix_match_enforces_dot_boundary( self, mock_settings, mock_logout, factory, view ): # "foss.arbisoft.com.evil" must not match the "foss.arbisoft.com" entry. - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" response = view.get( _make_request(factory, "next=https://foss.arbisoft.com.evil/x") @@ -145,7 +145,7 @@ def test_suffix_match_enforces_dot_boundary( def test_subdomain_matches_suffix_entry( self, mock_settings, mock_logout, factory, view ): - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" next_url = "https://pm.foss.arbisoft.com/portal/done" response = view.get(_make_request(factory, f"next={next_url}")) @@ -158,7 +158,7 @@ def test_subdomain_matches_suffix_entry( def test_empty_allowlist_rejects_all_next( self, mock_settings, mock_logout, factory, view ): - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = [] + mock_settings.PLATFORM_DOMAIN = "" response = view.get( _make_request(factory, "next=https://docs.foss.arbisoft.com/x") @@ -172,7 +172,7 @@ def test_malformed_next_is_rejected( self, mock_settings, mock_logout, factory, view ): # Garbage URL: no hostname extractable → reject. - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = ["foss.arbisoft.com"] + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" response = view.get(_make_request(factory, "next=not-a-url")) @@ -185,7 +185,7 @@ def test_allowlist_entry_with_leading_dot_is_normalised( ): # Operators sometimes write ".foss.arbisoft.com" — that leading dot # is stripped and the entry treated as a host suffix. - mock_settings.MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS = [".foss.arbisoft.com"] + mock_settings.PLATFORM_DOMAIN = ".foss.arbisoft.com" next_url = "https://pm.foss.arbisoft.com/done" response = view.get(_make_request(factory, f"next={next_url}")) From d695600ac17066d7cc93c2af446333224f61933f Mon Sep 17 00:00:00 2001 From: awais786 Date: Mon, 18 May 2026 17:45:34 +0500 Subject: [PATCH 3/3] refactor(auth): fold portal-logout into existing SignOutAuthEndpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: Plane already has SignOutAuthEndpoint that calls django.contrib.auth.logout(). Adding a parallel PortalSignOutEndpoint duplicated logic. Fold the cross-origin GET variant into the existing view instead — one endpoint, two methods, single logout primitive. - signout.py: add GET method with ?next= validation; share _flush_session() between POST and GET so both record last_logout_time/_ip and call logout() identically. - urls.py: drop the separate /portal-sign-out/ path; both methods now hit /auth/sign-out/. - portal_signout.py: deleted (functionality merged). - test_signout.py: add GET-side tests (allowlisted next, disallowed host, dot-boundary, empty PLATFORM_DOMAIN, fallback to MPASS_SIGNOUT_URL, malformed next). - test_portal_signout.py: deleted (tests merged into test_signout.py). Net: -95 lines vs the prior commit; single source of logout truth. --- apps/api/.env.example | 8 +- apps/api/plane/authentication/urls.py | 14 +- .../plane/authentication/views/__init__.py | 1 - .../views/app/portal_signout.py | 83 -------- .../plane/authentication/views/app/signout.py | 67 +++++- .../tests/unit/views/test_portal_signout.py | 194 ------------------ .../plane/tests/unit/views/test_signout.py | 122 +++++++++++ 7 files changed, 197 insertions(+), 292 deletions(-) delete mode 100644 apps/api/plane/authentication/views/app/portal_signout.py delete mode 100644 apps/api/plane/tests/unit/views/test_portal_signout.py diff --git a/apps/api/.env.example b/apps/api/.env.example index 44fdca1bc06..586ae43fc43 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -79,6 +79,8 @@ API_KEY_RATE_LIMIT="60/minute" # MPASS_BYPASS_PATHS=/god-mode,/api/instances # Required for 3-layer logout (Django → oauth2-proxy → Cognito) # MPASS_SIGNOUT_URL=https://foss-auth.local.moneta.dev/oauth2/sign_out?rd=https%3A%2F%2Fcognito.example.com%2Flogout -# Allowlist for /auth/portal-sign-out/?next= redirect targets — comma-separated -# host suffixes. Used by the foss-bundle portal's "Log out of all apps" chain. -# MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS=foss.arbisoft.com,localhost +# Root domain of the foss-server-bundle deployment. Used by +# /auth/sign-out/?next= as the redirect allowlist — only URLs whose +# host equals PLATFORM_DOMAIN or is a subdomain of it are followed. +# Set automatically by foss-server-bundle/platform.sh. +# PLATFORM_DOMAIN=foss.arbisoft.com diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index 2552a1d3c85..47f443be84d 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -6,7 +6,6 @@ from .views import ( CSRFTokenEndpoint, - PortalSignOutEndpoint, SignOutAuthEndpoint, SignOutAuthSpaceEndpoint, ) @@ -34,17 +33,12 @@ # GiteaOauthInitiateSpaceEndpoint, GiteaCallbackSpaceEndpoint, urlpatterns = [ - # signout — kept active (used by frontend 3-layer logout) + # signout — POST for in-app logout, GET (?next=) for the foss-bundle + # portal's "Log out of all apps" redirect chain. Both methods flush + # the Django session via the same django.contrib.auth.logout() call; + # GET is CSRF-exempt with PLATFORM_DOMAIN-bounded ?next= validation. path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"), path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"), - # portal-driven signout — GET-able, CSRF-exempt, used by the foss-bundle - # portal's "Log out of all apps" redirect chain to clear the Django - # session cookie while the browser is on this app's domain. - path( - "portal-sign-out/", - PortalSignOutEndpoint.as_view(), - name="portal-sign-out", - ), # csrf token — kept active (Django forms need it) path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index 45639d62211..a9c816ae9ea 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -14,7 +14,6 @@ from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint from .app.signout import SignOutAuthEndpoint -from .app.portal_signout import PortalSignOutEndpoint from .space.email import SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint diff --git a/apps/api/plane/authentication/views/app/portal_signout.py b/apps/api/plane/authentication/views/app/portal_signout.py deleted file mode 100644 index f6bd73e06aa..00000000000 --- a/apps/api/plane/authentication/views/app/portal_signout.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright (c) 2023-present Plane Software, Inc. and contributors -# SPDX-License-Identifier: AGPL-3.0-only -# See the LICENSE file for details. - -from urllib.parse import urlparse - -from django.conf import settings -from django.contrib.auth import logout -from django.http import HttpResponseBadRequest, HttpResponseRedirect -from django.utils.decorators import method_decorator -from django.views import View -from django.views.decorators.csrf import csrf_exempt - - -@method_decorator(csrf_exempt, name="dispatch") -class PortalSignOutEndpoint(View): - """ - GET /auth/portal-sign-out/?next= - - Clears the Django session and 302-redirects the browser to ``next``. - - Designed for the foss-server-bundle portal's "Log out of all apps" - redirect chain — the portal can navigate the browser through each - app's portal-sign-out URL, each step clearing its own session cookie - while the browser is on that app's own domain (so the Set-Cookie - scope is correct). - - CSRF-exempt: no token is shared cross-origin with the portal, so the - POST + CSRF flow used by the in-app SignOutAuthEndpoint isn't usable - here. The residual risk is force-logout (an attacker embeds - ```` and the victim's session ends). - That's low impact (annoying, not destructive — the only state lost is - the session itself, and re-auth via ForwardAuth is automatic). - - The ``next`` URL is validated against ``PLATFORM_DOMAIN`` (set by - foss-server-bundle/platform.sh) to prevent this endpoint from being - weaponised as an open redirect. The URL's host must equal - ``PLATFORM_DOMAIN`` exactly or be a subdomain of it. Dot boundary - enforced: ``foss.arbisoft.com`` matches subdomains but NOT - ``foss.arbisoft.com.evil.example``. - """ - - def get(self, request): - logout(request) - - next_url = (request.GET.get("next") or "").strip() - - if next_url: - if not self._is_allowed_next(next_url): - return HttpResponseBadRequest( - "next= target host is not a subdomain of PLATFORM_DOMAIN" - ) - return HttpResponseRedirect(next_url) - - # No next= supplied — fall back to MPASS_SIGNOUT_URL so a manual hit - # to this endpoint still chains through oauth2-proxy + Cognito. - fallback = getattr(settings, "MPASS_SIGNOUT_URL", "") or "/" - return HttpResponseRedirect(fallback) - - @staticmethod - def _is_allowed_next(url): - """True iff the URL's host equals PLATFORM_DOMAIN or is a subdomain. - - Suffix match enforces a dot boundary: ``foss.arbisoft.com`` - matches ``foss.arbisoft.com`` and ``*.foss.arbisoft.com``, but - not ``foss.arbisoft.com.evil``. Unset PLATFORM_DOMAIN → False - (every next= rejected). - """ - platform_domain = ( - getattr(settings, "PLATFORM_DOMAIN", "") or "" - ).strip().lower().lstrip(".") - if not platform_domain: - return False - - try: - host = urlparse(url).hostname - except ValueError: - return False - if not host: - return False - - host = host.lower() - return host == platform_domain or host.endswith("." + platform_domain) diff --git a/apps/api/plane/authentication/views/app/signout.py b/apps/api/plane/authentication/views/app/signout.py index 65aa29aef4e..3774ea0d5e9 100644 --- a/apps/api/plane/authentication/views/app/signout.py +++ b/apps/api/plane/authentication/views/app/signout.py @@ -2,18 +2,24 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +# Standard library +from urllib.parse import urlparse + # Django imports from django.views import View from django.contrib.auth import logout from django.conf import settings -from django.http import HttpResponseRedirect +from django.http import HttpResponseBadRequest, HttpResponseRedirect from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt # Module imports from plane.authentication.utils.host import user_ip, base_host from plane.db.models import User +@method_decorator(csrf_exempt, name="dispatch") class SignOutAuthEndpoint(View): def post(self, request): try: @@ -36,3 +42,62 @@ def post(self, request): return HttpResponseRedirect(mpass_signout_url) return HttpResponseRedirect(base_host(request=request, is_app=True)) + + def get(self, request): + """ + Cross-origin redirect-chain entry-point for the foss-server-bundle + portal's "Log out of all apps" flow. + + Clears the Django session via django.contrib.auth.logout(), then + 302s to ?next= (validated against PLATFORM_DOMAIN to prevent open + redirect). The chain works because the browser is on this app's + own domain when this endpoint runs — so Django's Set-Cookie scope + is correct. + + CSRF-exempt (set at class level): no token is shared cross-origin + with the portal. Residual risk is force-logout (attacker embeds + ending the victim's session); low impact — + only the session itself is lost, and re-auth via ForwardAuth is + automatic. + """ + logout(request) + + next_url = (request.GET.get("next") or "").strip() + if next_url: + if not self._is_allowed_next(next_url): + return HttpResponseBadRequest( + "next= target host is not a subdomain of PLATFORM_DOMAIN" + ) + return HttpResponseRedirect(next_url) + + # No ?next= — fall back to MPASS_SIGNOUT_URL so a manual hit still + # chains through oauth2-proxy + Cognito. + mpass_signout_url = getattr(settings, "MPASS_SIGNOUT_URL", None) + if mpass_signout_url: + return HttpResponseRedirect(mpass_signout_url) + return HttpResponseRedirect(base_host(request=request, is_app=True)) + + @staticmethod + def _is_allowed_next(url): + """True iff URL's host equals PLATFORM_DOMAIN or is a subdomain. + + Suffix match enforces a dot boundary: foss.arbisoft.com matches + foss.arbisoft.com and *.foss.arbisoft.com but NOT + foss.arbisoft.com.evil. Unset PLATFORM_DOMAIN → False (every + ?next= rejected). + """ + platform_domain = ( + getattr(settings, "PLATFORM_DOMAIN", "") or "" + ).strip().lower().lstrip(".") + if not platform_domain: + return False + + try: + host = urlparse(url).hostname + except ValueError: + return False + if not host: + return False + + host = host.lower() + return host == platform_domain or host.endswith("." + platform_domain) diff --git a/apps/api/plane/tests/unit/views/test_portal_signout.py b/apps/api/plane/tests/unit/views/test_portal_signout.py deleted file mode 100644 index 5e387f963e8..00000000000 --- a/apps/api/plane/tests/unit/views/test_portal_signout.py +++ /dev/null @@ -1,194 +0,0 @@ -# Copyright (c) 2023-present Plane Software, Inc. and contributors -# SPDX-License-Identifier: AGPL-3.0-only -# See the LICENSE file for details. - -""" -Unit tests for PortalSignOutEndpoint. - -SPEC (GIVEN / WHEN / THEN) -────────────────────────── - 1 GIVEN a valid ?next= URL on an allowlisted host - WHEN GET /auth/portal-sign-out/?next=… is called - THEN Django session is cleared and response 302s to that URL - - 2 GIVEN ?next= is omitted - WHEN GET /auth/portal-sign-out/ is called with MPASS_SIGNOUT_URL set - THEN response 302s to MPASS_SIGNOUT_URL (session still cleared) - - 3 GIVEN ?next= is omitted AND MPASS_SIGNOUT_URL is unset - WHEN GET /auth/portal-sign-out/ is called - THEN response 302s to "/" - - 4 GIVEN ?next= is on a host not in MPASS_SIGNOUT_NEXT_ALLOWED_HOSTS - WHEN GET /auth/portal-sign-out/?next=https://evil.example/ is called - THEN response is 400 Bad Request — open-redirect protection - - 5 GIVEN allowlist contains "foss.arbisoft.com" - WHEN ?next= hostname is "foss.arbisoft.com.evil" - THEN response is 400 — suffix match enforces dot boundary - - 6 GIVEN allowlist contains "foss.arbisoft.com" - WHEN ?next= hostname is "docs.foss.arbisoft.com" (subdomain) - THEN response 302s to that URL — subdomain allowed - - 7 GIVEN allowlist is empty - WHEN any ?next= is supplied - THEN response is 400 — empty allowlist rejects every redirect - - 8 GIVEN ?next= contains a malformed URL - WHEN GET /auth/portal-sign-out/?next=:::garbage is called - THEN response is 400 — defensive; we don't redirect to junk -""" - -from unittest.mock import MagicMock, patch - -import pytest -from django.test import RequestFactory - -from plane.authentication.views.app.portal_signout import PortalSignOutEndpoint - -pytestmark = pytest.mark.unit - - -def _make_request(factory: RequestFactory, query: str = "") -> MagicMock: - path = "/auth/portal-sign-out/" + (f"?{query}" if query else "") - req = factory.get(path) - req.user = MagicMock(id="user-uuid-1234") - return req - - -@pytest.fixture -def factory(): - return RequestFactory() - - -@pytest.fixture -def view(): - return PortalSignOutEndpoint() - - -@pytest.mark.unit -class TestPortalSignOutEndpoint: - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_redirects_to_allowlisted_next( - self, mock_settings, mock_logout, factory, view - ): - mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" - next_url = "https://docs.foss.arbisoft.com/auth/portal-sign-out/" - - response = view.get(_make_request(factory, f"next={next_url}")) - - mock_logout.assert_called_once() - assert response.status_code == 302 - assert response["Location"] == next_url - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_falls_back_to_mpass_signout_url_when_no_next( - self, mock_settings, mock_logout, factory, view - ): - mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" - mock_settings.MPASS_SIGNOUT_URL = "https://auth.foss.arbisoft.com/oauth2/sign_out" - - response = view.get(_make_request(factory)) - - mock_logout.assert_called_once() - assert response.status_code == 302 - assert response["Location"] == "https://auth.foss.arbisoft.com/oauth2/sign_out" - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_falls_back_to_root_when_no_next_and_no_mpass_url( - self, mock_settings, mock_logout, factory, view - ): - mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" - mock_settings.MPASS_SIGNOUT_URL = "" - - response = view.get(_make_request(factory)) - - mock_logout.assert_called_once() - assert response.status_code == 302 - assert response["Location"] == "/" - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_rejects_next_on_disallowed_host( - self, mock_settings, mock_logout, factory, view - ): - mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" - - response = view.get( - _make_request(factory, "next=https://evil.example/steal") - ) - - mock_logout.assert_called_once() # session is still flushed - assert response.status_code == 400 - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_suffix_match_enforces_dot_boundary( - self, mock_settings, mock_logout, factory, view - ): - # "foss.arbisoft.com.evil" must not match the "foss.arbisoft.com" entry. - mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" - - response = view.get( - _make_request(factory, "next=https://foss.arbisoft.com.evil/x") - ) - - assert response.status_code == 400 - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_subdomain_matches_suffix_entry( - self, mock_settings, mock_logout, factory, view - ): - mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" - next_url = "https://pm.foss.arbisoft.com/portal/done" - - response = view.get(_make_request(factory, f"next={next_url}")) - - assert response.status_code == 302 - assert response["Location"] == next_url - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_empty_allowlist_rejects_all_next( - self, mock_settings, mock_logout, factory, view - ): - mock_settings.PLATFORM_DOMAIN = "" - - response = view.get( - _make_request(factory, "next=https://docs.foss.arbisoft.com/x") - ) - - assert response.status_code == 400 - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_malformed_next_is_rejected( - self, mock_settings, mock_logout, factory, view - ): - # Garbage URL: no hostname extractable → reject. - mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" - - response = view.get(_make_request(factory, "next=not-a-url")) - - assert response.status_code == 400 - - @patch("plane.authentication.views.app.portal_signout.logout") - @patch("plane.authentication.views.app.portal_signout.settings") - def test_allowlist_entry_with_leading_dot_is_normalised( - self, mock_settings, mock_logout, factory, view - ): - # Operators sometimes write ".foss.arbisoft.com" — that leading dot - # is stripped and the entry treated as a host suffix. - mock_settings.PLATFORM_DOMAIN = ".foss.arbisoft.com" - next_url = "https://pm.foss.arbisoft.com/done" - - response = view.get(_make_request(factory, f"next={next_url}")) - - assert response.status_code == 302 - assert response["Location"] == next_url diff --git a/apps/api/plane/tests/unit/views/test_signout.py b/apps/api/plane/tests/unit/views/test_signout.py index 6ba375e0988..44e2af67e0c 100644 --- a/apps/api/plane/tests/unit/views/test_signout.py +++ b/apps/api/plane/tests/unit/views/test_signout.py @@ -129,3 +129,125 @@ def test_falls_back_to_base_host_when_save_raises_and_no_mpass_url( assert response.status_code == 302 assert response["Location"] == _BASE_HOST + + +# GET /auth/sign-out/?next= — portal logout-chain entry point +# ──────────────────────────────────────────────────────────────── + + +def _make_get_request(factory: RequestFactory, qs: str = "") -> MagicMock: + url = "/auth/sign-out/" + (f"?{qs}" if qs else "") + req = factory.get(url) + req.user = MagicMock(id="user-uuid-1234") + return req + + +@pytest.mark.unit +class TestSignOutAuthEndpointGet: + """The GET variant: portal-driven logout chain. + + Same `_flush_session()` body as POST. Differences: + - ?next= allowlist validated against PLATFORM_DOMAIN (host equality + or dot-bounded subdomain). + - CSRF-exempt (set at the class level via @method_decorator). + - Falls back to MPASS_SIGNOUT_URL or base_host when ?next= is + missing / invalid. + """ + + @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST) + @patch("plane.authentication.views.app.signout.logout") + @patch("plane.authentication.views.app.signout.User") + @patch("plane.authentication.views.app.signout.settings") + def test_redirects_to_allowlisted_next( + self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view + ): + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" + mock_user_cls.objects.get.return_value = MagicMock() + next_url = "https://docs.foss.arbisoft.com/auth/sign-out/" + + response = view.get(_make_get_request(factory, f"next={next_url}")) + + mock_logout.assert_called_once() # GET still flushes the session + assert response.status_code == 302 + assert response["Location"] == next_url + + @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST) + @patch("plane.authentication.views.app.signout.logout") + @patch("plane.authentication.views.app.signout.User") + @patch("plane.authentication.views.app.signout.settings") + def test_rejects_next_on_disallowed_host( + self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view + ): + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" + mock_user_cls.objects.get.return_value = MagicMock() + + response = view.get( + _make_get_request(factory, "next=https://evil.example/steal") + ) + + mock_logout.assert_called_once() # session still flushed + assert response.status_code == 400 + + @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST) + @patch("plane.authentication.views.app.signout.logout") + @patch("plane.authentication.views.app.signout.User") + @patch("plane.authentication.views.app.signout.settings") + def test_suffix_match_enforces_dot_boundary( + self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view + ): + """foss.arbisoft.com.evil must not match foss.arbisoft.com.""" + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" + mock_user_cls.objects.get.return_value = MagicMock() + + response = view.get( + _make_get_request(factory, "next=https://foss.arbisoft.com.evil/x") + ) + + assert response.status_code == 400 + + @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST) + @patch("plane.authentication.views.app.signout.logout") + @patch("plane.authentication.views.app.signout.User") + @patch("plane.authentication.views.app.signout.settings") + def test_empty_platform_domain_rejects_all_next( + self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view + ): + mock_settings.PLATFORM_DOMAIN = "" + mock_user_cls.objects.get.return_value = MagicMock() + + response = view.get( + _make_get_request(factory, "next=https://docs.foss.arbisoft.com/x") + ) + + assert response.status_code == 400 + + @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST) + @patch("plane.authentication.views.app.signout.logout") + @patch("plane.authentication.views.app.signout.User") + @patch("plane.authentication.views.app.signout.settings") + def test_no_next_falls_back_to_mpass_url( + self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view + ): + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" + mock_settings.MPASS_SIGNOUT_URL = _MPASS_URL + mock_user_cls.objects.get.return_value = MagicMock() + + response = view.get(_make_get_request(factory)) + + mock_logout.assert_called_once() + assert response.status_code == 302 + assert response["Location"] == _MPASS_URL + + @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST) + @patch("plane.authentication.views.app.signout.logout") + @patch("plane.authentication.views.app.signout.User") + @patch("plane.authentication.views.app.signout.settings") + def test_malformed_next_is_rejected( + self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view + ): + mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com" + mock_user_cls.objects.get.return_value = MagicMock() + + response = view.get(_make_get_request(factory, "next=:::garbage")) + + assert response.status_code == 400