diff --git a/apps/api/.env.example b/apps/api/.env.example index 6bed24003b4..586ae43fc43 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -79,3 +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 +# 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 bc736087fcb..47f443be84d 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -33,7 +33,10 @@ # 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"), # csrf token — kept active (Django forms need it) 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/settings/common.py b/apps/api/plane/settings/common.py index 6d23c23a8d7..d7c23634142 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -62,6 +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") +# 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_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