Add validation to prevent privilege escalation via is_superuser field and enhance related tests
- Added explicit Pydantic validation to reject modifications to `is_superuser` in `UserUpdate` schema. - Updated backend logic in `users.py` to support defense-in-depth against privilege escalation. - Introduced comprehensive tests for `/users` and `/users/me` endpoints to ensure `is_superuser` validation works correctly. - Enhanced error handling and validation messages for better clarity and robustness.
This commit is contained in:
@@ -146,10 +146,10 @@ async def update_current_user(
|
|||||||
Users cannot elevate their own permissions (is_superuser).
|
Users cannot elevate their own permissions (is_superuser).
|
||||||
"""
|
"""
|
||||||
# Prevent users from making themselves superuser
|
# Prevent users from making themselves superuser
|
||||||
# NOTE: UserUpdate schema doesn't include is_superuser, so this is dead code
|
# NOTE: Pydantic validator will reject is_superuser != None, but this provides defense in depth
|
||||||
if getattr(user_update, 'is_superuser', None) is not None: # pragma: no cover
|
if getattr(user_update, 'is_superuser', None) is not None:
|
||||||
logger.warning(f"User {current_user.id} attempted to modify is_superuser field") # pragma: no cover
|
logger.warning(f"User {current_user.id} attempted to modify is_superuser field")
|
||||||
raise AuthorizationError( # pragma: no cover
|
raise AuthorizationError(
|
||||||
message="Cannot modify superuser status",
|
message="Cannot modify superuser status",
|
||||||
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
|
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
|
||||||
)
|
)
|
||||||
@@ -266,10 +266,10 @@ async def update_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Prevent non-superusers from modifying superuser status
|
# Prevent non-superusers from modifying superuser status
|
||||||
# NOTE: UserUpdate schema doesn't include is_superuser, so this is dead code
|
# NOTE: Pydantic validator will reject is_superuser != None, but this provides defense in depth
|
||||||
if getattr(user_update, 'is_superuser', None) is not None and not current_user.is_superuser: # pragma: no cover
|
if getattr(user_update, 'is_superuser', None) is not None and not current_user.is_superuser:
|
||||||
logger.warning(f"User {current_user.id} attempted to modify is_superuser field") # pragma: no cover
|
logger.warning(f"User {current_user.id} attempted to modify is_superuser field")
|
||||||
raise AuthorizationError( # pragma: no cover
|
raise AuthorizationError(
|
||||||
message="Cannot modify superuser status",
|
message="Cannot modify superuser status",
|
||||||
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
|
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class UserUpdate(BaseModel):
|
|||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
preferences: Optional[Dict[str, Any]] = None
|
preferences: Optional[Dict[str, Any]] = None
|
||||||
is_active: Optional[bool] = None # Changed default from True to None to avoid unintended updates
|
is_active: Optional[bool] = None # Changed default from True to None to avoid unintended updates
|
||||||
|
is_superuser: Optional[bool] = None # Explicitly reject privilege escalation attempts
|
||||||
|
|
||||||
@field_validator('phone_number')
|
@field_validator('phone_number')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -52,6 +53,14 @@ class UserUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
return validate_password_strength(v)
|
return validate_password_strength(v)
|
||||||
|
|
||||||
|
@field_validator('is_superuser')
|
||||||
|
@classmethod
|
||||||
|
def prevent_superuser_modification(cls, v: Optional[bool]) -> Optional[bool]:
|
||||||
|
"""Prevent users from modifying their superuser status via this schema."""
|
||||||
|
if v is not None:
|
||||||
|
raise ValueError("Cannot modify superuser status through user update")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class UserInDB(UserBase):
|
class UserInDB(UserBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
|
|||||||
@@ -222,20 +222,22 @@ class TestUpdateCurrentUser:
|
|||||||
"""Test that users cannot make themselves superuser."""
|
"""Test that users cannot make themselves superuser."""
|
||||||
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
|
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
|
||||||
|
|
||||||
# Note: is_superuser is not in UserUpdate schema, but the endpoint checks for it
|
# Note: is_superuser is now in UserUpdate schema with explicit validation
|
||||||
# This tests that even if someone tries to send it, it's rejected
|
# This tests that Pydantic rejects the attempt at the schema level
|
||||||
response = await client.patch(
|
response = await client.patch(
|
||||||
"/api/v1/users/me",
|
"/api/v1/users/me",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"first_name": "Test", "is_superuser": True}
|
json={"first_name": "Test", "is_superuser": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should succeed since is_superuser is not in schema and gets ignored by Pydantic
|
# Pydantic validation should reject this at the schema level
|
||||||
# The actual protection is at the database/service layer
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
# Verify user is still not a superuser
|
assert data["success"] is False
|
||||||
assert data["is_superuser"] is False
|
assert "errors" in data
|
||||||
|
# Check that the error mentions is_superuser
|
||||||
|
error_fields = [err["field"] for err in data["errors"]]
|
||||||
|
assert "is_superuser" in error_fields
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_profile_no_auth(self, client):
|
async def test_update_profile_no_auth(self, client):
|
||||||
|
|||||||
@@ -112,6 +112,26 @@ class TestUpdateCurrentUser:
|
|||||||
json={"first_name": "Updated"}
|
json={"first_name": "Updated"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_current_user_cannot_make_superuser(self, client, user_token):
|
||||||
|
"""Test that users cannot make themselves superuser (Pydantic validation)."""
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/v1/users/me",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
json={"is_superuser": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pydantic validation should reject this at the schema level
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "errors" in data
|
||||||
|
# Check that the error mentions is_superuser
|
||||||
|
error_fields = [err["field"] for err in data["errors"]]
|
||||||
|
assert "is_superuser" in error_fields
|
||||||
|
error_messages = [err["message"] for err in data["errors"]]
|
||||||
|
assert any("superuser" in msg.lower() for msg in error_messages)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_current_user_value_error(self, client, user_token):
|
async def test_update_current_user_value_error(self, client, user_token):
|
||||||
"""Test ValueError handling during update (covers lines 165-166)."""
|
"""Test ValueError handling during update (covers lines 165-166)."""
|
||||||
@@ -168,6 +188,18 @@ class TestUpdateUserById:
|
|||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_user_by_id_non_superuser_cannot_change_superuser_status(self, client, async_test_user, user_token):
|
||||||
|
"""Test non-superuser cannot modify superuser status (Pydantic validation)."""
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/v1/users/{async_test_user.id}",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
json={"is_superuser": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pydantic validation should reject this at the schema level
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_user_by_id_success(self, client, async_test_user, superuser_token):
|
async def test_update_user_by_id_success(self, client, async_test_user, superuser_token):
|
||||||
"""Test updating user successfully (covers lines 276-278)."""
|
"""Test updating user successfully (covers lines 276-278)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user