Skip to content
Open
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
14 changes: 13 additions & 1 deletion api/custom_auth/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request

from organisations.invites.services import is_valid_registration_invite


class CurrentUser(IsAuthenticated):
"""
Expand All @@ -18,7 +20,17 @@ def has_object_permission(self, request, view, obj): # type: ignore[no-untyped-

class IsSignupAllowed(AllowAny):
def has_permission(self, request: Request, view: View) -> bool:
return not settings.PREVENT_SIGNUP
if not settings.PREVENT_SIGNUP:
return True

# Signups are otherwise prevented, but a valid invite should still
# let someone through: `PREVENT_SIGNUP` is meant to stop self-serve
# signup, not registration via an invite link or invited email.
return is_valid_registration_invite(
sign_up_type=request.data.get("sign_up_type"),
email=request.data.get("email") or "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Normalise raw email input before invite validation.

When PREVENT_SIGNUP is enabled, a request with sign_up_type set to invited email and "email": ["user@example.com"] passes this truthy list to is_valid_registration_invite. The service then calls email.lower() and raises AttributeError before serializer validation. Reject non-string values so this request returns False instead of a server error.

Proposed fix
+        email = request.data.get("email")
         return is_valid_registration_invite(
             sign_up_type=request.data.get("sign_up_type"),
-            email=request.data.get("email") or "",
+            email=email if isinstance(email, str) else "",
             invite_hash=request.data.get("invite_hash"),
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
email=request.data.get("email") or "",
email = request.data.get("email")
return is_valid_registration_invite(
sign_up_type=request.data.get("sign_up_type"),
email=email if isinstance(email, str) else "",
invite_hash=request.data.get("invite_hash"),
)

invite_hash=request.data.get("invite_hash"),
)


class IsPasswordLoginAllowed(AllowAny):
Expand Down
20 changes: 7 additions & 13 deletions api/custom_auth/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import PermissionDenied

from organisations.invites.models import Invite, InviteLink
from organisations.invites.services import is_valid_registration_invite
from users.auth_type import AuthType
from users.constants import DEFAULT_DELETE_ORPHAN_ORGANISATIONS_VALUE
from users.models import FFAdminUser, SignUpType
from users.models import FFAdminUser

from .constants import (
FIELD_BLANK_ERROR,
Expand All @@ -32,17 +32,11 @@ def _validate_registration_invite(self, email: str, sign_up_type: str) -> None:
if settings.ALLOW_REGISTRATION_WITHOUT_INVITE:
return

valid = False

match sign_up_type:
case SignUpType.INVITE_LINK.value:
valid = InviteLink.objects.filter(
hash=self.initial_data.get("invite_hash") # type: ignore[attr-defined]
).exists()
case SignUpType.INVITE_EMAIL.value:
valid = Invite.objects.filter(email__iexact=email.lower()).exists()

if not valid:
if not is_valid_registration_invite(
sign_up_type=sign_up_type,
email=email,
invite_hash=self.initial_data.get("invite_hash"), # type: ignore[attr-defined]
):
raise PermissionDenied(USER_REGISTRATION_WITHOUT_INVITE_ERROR_MESSAGE)


Expand Down
15 changes: 15 additions & 0 deletions api/organisations/invites/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from users.models import SignUpType

from .models import Invite, InviteLink


def is_valid_registration_invite(
*, sign_up_type: str | None, email: str, invite_hash: str | None
) -> bool:
match sign_up_type:
case SignUpType.INVITE_LINK.value:
return InviteLink.objects.filter(hash=invite_hash).exists()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C5 'class AbstractBaseInviteModel|class InviteLink|objects\s*=|class .*Manager|expires_at|is_expired' api
rg -n -C6 'is_valid_registration_invite|PREVENT_SIGNUP|_validate_registration_invite' api/custom_auth api/organisations

Repository: Flagsmith/flagsmith

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- invite models ---'
sed -n '1,65p' api/organisations/invites/models.py

printf '%s\n' '--- shared registration checks ---'
sed -n '1,80p' api/custom_auth/permissions.py
sed -n '1,60p' api/custom_auth/serializers.py

printf '%s\n' '--- scoped review conventions and learnings ---'
find /tmp/coderabbit-repo-knowledge/flagsmith-flagsmith-a74d157a -type f \
  \( -path '*/api/*' -o -path '*/organisations/*' -o -path '*/custom_auth/*' \) \
  -name '*.md' -print

Repository: Flagsmith/flagsmith

Length of output: 6009


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,55p' api/core/models.py
sed -n '1,30p' api/organisations/invites/services.py

Repository: Flagsmith/flagsmith

Length of output: 2214


Authorization Bypass (CWE-862): Missing Authorization

Reachability: External · Exploitability: Moderate

Exclude expired invite links from registration validation.

InviteLink.objects uses an unfiltered manager, so this hash-only check accepts expired links. Filter for active links and add an expired-link regression test.

case SignUpType.INVITE_EMAIL.value:
return Invite.objects.filter(email__iexact=email.lower()).exists()
case _:
return False
107 changes: 107 additions & 0 deletions api/tests/unit/custom_auth/test_unit_custom_auth_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from unittest import mock

from pytest_django.fixtures import SettingsWrapper

from custom_auth.permissions import IsSignupAllowed
from organisations.invites.models import Invite, InviteLink
from organisations.models import Organisation
from users.models import SignUpType


def test_is_signup_allowed__prevent_signup_disabled__returns_true(
settings: SettingsWrapper,
) -> None:
# Given
settings.PREVENT_SIGNUP = False
permission = IsSignupAllowed()
mock_request = mock.MagicMock(data={})

# When
result = permission.has_permission(mock_request, mock.MagicMock())

# Then
assert result is True


def test_is_signup_allowed__prevent_signup_enabled_no_invite__returns_false(
settings: SettingsWrapper,
) -> None:
# Given
settings.PREVENT_SIGNUP = True
permission = IsSignupAllowed()
mock_request = mock.MagicMock(data={"email": "test@example.com"})

# When
result = permission.has_permission(mock_request, mock.MagicMock())

# Then
assert result is False


def test_is_signup_allowed__prevent_signup_enabled_valid_invite_link__returns_true(
db: None,
settings: SettingsWrapper,
organisation: Organisation,
) -> None:
# Given
settings.PREVENT_SIGNUP = True
invite_link = InviteLink.objects.create(organisation=organisation)
permission = IsSignupAllowed()
mock_request = mock.MagicMock(
data={
"email": "test@example.com",
"sign_up_type": SignUpType.INVITE_LINK.value,
"invite_hash": invite_link.hash,
}
)

# When
result = permission.has_permission(mock_request, mock.MagicMock())

# Then
assert result is True


def test_is_signup_allowed__prevent_signup_enabled_invalid_invite_hash__returns_false(
db: None,
settings: SettingsWrapper,
) -> None:
# Given
settings.PREVENT_SIGNUP = True
permission = IsSignupAllowed()
mock_request = mock.MagicMock(
data={
"email": "test@example.com",
"sign_up_type": SignUpType.INVITE_LINK.value,
"invite_hash": "invalid-hash",
}
)

# When
result = permission.has_permission(mock_request, mock.MagicMock())

# Then
assert result is False


def test_is_signup_allowed__prevent_signup_enabled_valid_invite_email__returns_true(
db: None,
settings: SettingsWrapper,
organisation: Organisation,
) -> None:
# Given
settings.PREVENT_SIGNUP = True
Invite.objects.create(email="test@example.com", organisation=organisation)
permission = IsSignupAllowed()
mock_request = mock.MagicMock(
data={
"email": "Test@Example.com",
"sign_up_type": SignUpType.INVITE_EMAIL.value,
}
)

# When
result = permission.has_permission(mock_request, mock.MagicMock())

# Then
assert result is True
Loading