Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion apps/api/plane/authentication/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 66 additions & 1 deletion apps/api/plane/authentication/views/app/signout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -36,3 +42,62 @@
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
<img src="…/sign-out"> 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)

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.

# 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)
6 changes: 6 additions & 0 deletions apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
122 changes: 122 additions & 0 deletions apps/api/plane/tests/unit/views/test_signout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<url> — 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
Loading