fix(security): block bot user logins (#9368)

Bot service accounts (User.is_bot=True, e.g. the WORKSPACE_SEED bot) are
internal identities meant to act only through API tokens. Nothing stopped
one from being driven through the interactive login flow if its email was
known, letting a human assume a service identity.

Reject bot accounts at the shared login chokepoint,
Adapter.complete_login_or_signup(), right beside the existing
deactivated-account check. This covers every interactive provider in one
place: email/password, magic code, and all OAuth providers (Google, GitHub,
GitLab, Gitea) across both the app and space surfaces. Bot API-token access
is left untouched, since that is how bots are meant to operate.

Also add a defense-in-depth is_bot guard to InstanceAdminSignInEndpoint,
which mints its own admin session outside the chokepoint (a bot is never an
InstanceAdmin today, so this is not currently reachable, but it closes the
path regardless).

Surface the rejection with a new dedicated error code
BOT_USER_LOGIN_FORBIDDEN (5017), plumbed into the app and space frontend
error helpers as well as the shared @plane/constants and @plane/utils
packages (message map + banner-alert list) so any consumer of the shared
auth-error handler renders it correctly. The admin path reuses the existing
ADMIN_AUTHENTICATION_FAILED code so it discloses no bot-specific error.

Add contract regression tests: a bot blocked via password and via magic
code, a bot blocked at the admin sign-in endpoint, and a non-bot control
that still logs in.
This commit is contained in:
sriram veeraghanta
2026-07-08 01:51:37 +05:30
committed by GitHub
parent d5dda5d41c
commit 4fc79a2d7e
8 changed files with 178 additions and 0 deletions

View File

@@ -330,6 +330,19 @@ class Adapter:
payload={"email": email},
)
# Reject bot service accounts (BOT_USER_LOGIN_FORBIDDEN). Bots (is_bot=True,
# e.g. the WORKSPACE_SEED bot) are internal identities that act only through
# API tokens; they must never be assumable via the interactive login/signup
# flow (email/password, magic code, or any OAuth provider). A brand-new
# signup can never be a bot — bots are provisioned internally, never through
# this path — so guarding on an existing `user` record is sufficient.
if user and user.is_bot:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["BOT_USER_LOGIN_FORBIDDEN"],
error_message="BOT_USER_LOGIN_FORBIDDEN",
payload={"email": email},
)
# True = new user (signup), False = returning user (login)
is_signup = not bool(user)
# If user is not present, create a new user

View File

@@ -9,6 +9,7 @@ AUTHENTICATION_ERROR_CODES = {
"EMAIL_REQUIRED": 5010,
"SIGNUP_DISABLED": 5015,
"MAGIC_LINK_LOGIN_DISABLED": 5016,
"BOT_USER_LOGIN_FORBIDDEN": 5017,
"PASSWORD_LOGIN_DISABLED": 5018,
"USER_ACCOUNT_DEACTIVATED": 5019,
# Password strength

View File

@@ -333,6 +333,25 @@ class InstanceAdminSignInEndpoint(View):
)
return HttpResponseRedirect(url)
# Reject bot service accounts (defense-in-depth for the same intent as
# BOT_USER_LOGIN_FORBIDDEN on the app/space flow). Bots are internal
# identities that act only via API tokens and must never sign in to the
# admin console. A bot is never registered as an InstanceAdmin, so this
# is not reachable today, but the guard closes the path regardless.
# Reuse ADMIN_AUTHENTICATION_FAILED so no bot-specific admin error code
# is disclosed to the caller.
if user.is_bot:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_AUTHENTICATION_FAILED"],
error_message="ADMIN_AUTHENTICATION_FAILED",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# is_active
if not user.is_active:
exc = AuthenticationException(

View File

@@ -626,3 +626,130 @@ class TestAuthenticationThrottle:
response = django_client.post(url, {"email": "throttle-up@plane.so", "code": "000000"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" in response.url
@pytest.mark.contract
class TestBotUserLoginBlocked:
"""Bot service accounts (is_bot=True) must never authenticate through the
interactive login flow.
Bots are internal identities (e.g. the WORKSPACE_SEED bot) that act only via
API tokens. Every interactive provider funnels through
Adapter.complete_login_or_signup(), which rejects bots with
BOT_USER_LOGIN_FORBIDDEN (5017). These are regression guards for that block.
"""
BOT_EMAIL = "bot-login@plane.so"
HUMAN_EMAIL = "human-login@plane.so"
PASSWORD = "Str0ng-Pass!42"
@pytest.fixture(autouse=True)
def _clear_state(self):
"""Reset throttle cache and the bot's magic-link redis state around each test."""
cache.clear()
ri = redis_instance()
ri.delete(f"magic_{self.BOT_EMAIL}")
ri.delete(f"magic_{self.BOT_EMAIL}:verify_attempts")
yield
cache.clear()
ri.delete(f"magic_{self.BOT_EMAIL}")
ri.delete(f"magic_{self.BOT_EMAIL}:verify_attempts")
@pytest.fixture
def bot_user(self, db):
"""An active bot account with a known password so the credential check
passes and execution reaches the login chokepoint."""
user = User.objects.create(email=self.BOT_EMAIL, is_bot=True, is_active=True)
user.set_password(self.PASSWORD)
user.save()
return user
@pytest.fixture
def human_user(self, db):
"""A normal (non-bot) account, identical apart from is_bot, as a control."""
user = User.objects.create(email=self.HUMAN_EMAIL, is_active=True)
user.set_password(self.PASSWORD)
user.save()
return user
@pytest.mark.django_db
def test_bot_password_sign_in_blocked(self, django_client, bot_user, setup_instance):
"""Password sign-in with a bot's *correct* credentials is still rejected:
the block happens after credential verification, so no session is created."""
url = reverse("sign-in")
response = django_client.post(
url, {"email": self.BOT_EMAIL, "password": self.PASSWORD}, follow=False
)
assert response.status_code == 302
assert "BOT_USER_LOGIN_FORBIDDEN" in response.url
# The block must prevent authentication.
assert "_auth_user_id" not in django_client.session
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_bot_magic_sign_in_blocked(
self, mock_magic_link, django_client, api_client, bot_user, setup_instance
):
"""The same block applies via a second provider (magic code), proving the
guard sits at the shared chokepoint rather than in one provider."""
token = _generate_magic_token(api_client, self.BOT_EMAIL)
url = reverse("magic-sign-in")
response = django_client.post(url, {"email": self.BOT_EMAIL, "code": token}, follow=False)
assert response.status_code == 302
assert "BOT_USER_LOGIN_FORBIDDEN" in response.url
assert "_auth_user_id" not in django_client.session
@pytest.mark.django_db
def test_human_password_sign_in_allowed(self, django_client, human_user, setup_instance):
"""Control: a normal user with the identical setup still signs in — the
guard is scoped strictly to is_bot and does not regress human logins."""
url = reverse("sign-in")
response = django_client.post(
url, {"email": self.HUMAN_EMAIL, "password": self.PASSWORD}, follow=False
)
assert response.status_code == 302
assert "BOT_USER_LOGIN_FORBIDDEN" not in response.url
assert "error_code" not in response.url
assert "_auth_user_id" in django_client.session
@pytest.mark.contract
class TestBotUserAdminSignInBlocked:
"""A bot must not sign in to the instance-admin console either.
InstanceAdminSignInEndpoint mints its own session via user_login() outside
Adapter.complete_login_or_signup(), so it carries an independent is_bot
guard that rejects bots with ADMIN_AUTHENTICATION_FAILED before the admin
membership check. (Uses the literal path because license/urls.py reuses the
name "instance-admin-sign-in" for both sign-in and sign-up.)
"""
ADMIN_SIGN_IN_PATH = "/api/instances/admins/sign-in/"
BOT_EMAIL = "admin-bot@plane.so"
PASSWORD = "Str0ng-Pass!42"
@pytest.fixture(autouse=True)
def _clear_state(self):
cache.clear()
yield
cache.clear()
@pytest.fixture
def bot_user(self, db):
user = User.objects.create(email=self.BOT_EMAIL, is_bot=True, is_active=True)
user.set_password(self.PASSWORD)
user.save()
return user
@pytest.mark.django_db
def test_bot_admin_sign_in_blocked(self, django_client, bot_user, setup_instance):
"""A bot is rejected at the admin sign-in endpoint and no session is created,
even though it is active and the password is correct."""
response = django_client.post(
self.ADMIN_SIGN_IN_PATH,
{"email": self.BOT_EMAIL, "password": self.PASSWORD},
follow=False,
)
assert response.status_code == 302
assert "ADMIN_AUTHENTICATION_FAILED" in response.url
assert "_auth_user_id" not in django_client.session

View File

@@ -42,6 +42,7 @@ export enum EAuthenticationErrorCodes {
INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
// Sign In
BOT_USER_LOGIN_FORBIDDEN = "5017",
USER_ACCOUNT_DEACTIVATED = "5019",
USER_DOES_NOT_EXIST = "5060",
AUTHENTICATION_FAILED_SIGN_IN = "5065",
@@ -160,6 +161,10 @@ const errorCodeMessages: {
},
// sign in
[EAuthenticationErrorCodes.BOT_USER_LOGIN_FORBIDDEN]: {
title: `Sign in not allowed`,
message: () => `This account cannot be used to sign in. Please use a personal account.`,
},
[EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
@@ -384,6 +389,7 @@ export const authErrorHandler = (errorCode: EAuthenticationErrorCodes, email?: s
EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED,
EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST,
EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
EAuthenticationErrorCodes.BOT_USER_LOGIN_FORBIDDEN,
EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED,
];

View File

@@ -43,6 +43,7 @@ export enum EAuthenticationErrorCodes {
EMAIL_REQUIRED = "5010",
SIGNUP_DISABLED = "5015",
MAGIC_LINK_LOGIN_DISABLED = "5016",
BOT_USER_LOGIN_FORBIDDEN = "5017",
PASSWORD_LOGIN_DISABLED = "5018",
USER_ACCOUNT_DEACTIVATED = "5019",
// Password strength
@@ -141,6 +142,10 @@ const errorCodeMessages: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
},
[EAuthenticationErrorCodes.BOT_USER_LOGIN_FORBIDDEN]: {
title: `Sign in not allowed`,
message: () => `This account cannot be used to sign in. Please use a personal account.`,
},
[EAuthenticationErrorCodes.INVALID_PASSWORD]: {
title: `Invalid password`,
message: () => `Invalid password. Please try again.`,
@@ -380,6 +385,7 @@ export const authErrorHandler = (errorCode: EAuthenticationErrorCodes, email?: s
EAuthenticationErrorCodes.SIGNUP_DISABLED,
EAuthenticationErrorCodes.MAGIC_LINK_LOGIN_DISABLED,
EAuthenticationErrorCodes.PASSWORD_LOGIN_DISABLED,
EAuthenticationErrorCodes.BOT_USER_LOGIN_FORBIDDEN,
EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED,
EAuthenticationErrorCodes.INVALID_PASSWORD,
EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED,

View File

@@ -110,6 +110,7 @@ export enum EAuthErrorCodes {
EMAIL_REQUIRED = "5010",
SIGNUP_DISABLED = "5015",
MAGIC_LINK_LOGIN_DISABLED = "5016",
BOT_USER_LOGIN_FORBIDDEN = "5017",
PASSWORD_LOGIN_DISABLED = "5018",
USER_ACCOUNT_DEACTIVATED = "5019",
// Password strength

View File

@@ -142,6 +142,10 @@ const errorCodeMessages: {
message: () => `Invalid email. Please try again.`,
},
// sign in
[EAuthErrorCodes.BOT_USER_LOGIN_FORBIDDEN]: {
title: `Sign in not allowed`,
message: () => `This account cannot be used to sign in. Please use a personal account.`,
},
[EAuthErrorCodes.USER_ACCOUNT_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact administrator.`,
@@ -349,6 +353,7 @@ export const authErrorHandler = (errorCode: EAuthErrorCodes, email?: string): TA
EAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED,
EAuthErrorCodes.ADMIN_USER_ALREADY_EXIST,
EAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
EAuthErrorCodes.BOT_USER_LOGIN_FORBIDDEN,
EAuthErrorCodes.USER_ACCOUNT_DEACTIVATED,
];