cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
460 lines
17 KiB
Python
460 lines
17 KiB
Python
"""Security tests for multiuser authentication system.
|
|
|
|
This module tests various security aspects including:
|
|
- SQL injection prevention
|
|
- Authorization bypass attempts
|
|
- Session security
|
|
- Input validation
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from invokeai.app.api.dependencies import ApiDependencies
|
|
from invokeai.app.api_app import app
|
|
from invokeai.app.services.invoker import Invoker
|
|
from invokeai.app.services.users.users_common import UserCreateRequest
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="module")
|
|
def client(invokeai_root_dir: Path) -> TestClient:
|
|
"""Create a test client for the FastAPI app."""
|
|
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None:
|
|
"""Enable multiuser mode for auth tests.
|
|
|
|
Auth tests need multiuser mode enabled since the login/setup endpoints
|
|
return 403 when multiuser is disabled.
|
|
"""
|
|
mock_invoker.services.configuration.multiuser = True
|
|
|
|
|
|
class MockApiDependencies(ApiDependencies):
|
|
"""Mock API dependencies for testing."""
|
|
|
|
invoker: Invoker
|
|
|
|
def __init__(self, invoker) -> None:
|
|
self.invoker = invoker
|
|
|
|
|
|
def setup_test_user(mock_invoker: Invoker, email: str = "test@example.com", password: str = "TestPass123") -> str:
|
|
"""Helper to create a test user and return user_id."""
|
|
user_service = mock_invoker.services.users
|
|
user_data = UserCreateRequest(
|
|
email=email,
|
|
display_name="Test User",
|
|
password=password,
|
|
is_admin=False,
|
|
)
|
|
user = user_service.create(user_data)
|
|
return user.user_id
|
|
|
|
|
|
def setup_test_admin(mock_invoker: Invoker, email: str = "admin@example.com", password: str = "AdminPass123") -> str:
|
|
"""Helper to create a test admin user and return user_id."""
|
|
user_service = mock_invoker.services.users
|
|
user_data = UserCreateRequest(
|
|
email=email,
|
|
display_name="Admin User",
|
|
password=password,
|
|
is_admin=True,
|
|
)
|
|
user = user_service.create(user_data)
|
|
return user.user_id
|
|
|
|
|
|
class TestSQLInjectionPrevention:
|
|
"""Tests to ensure SQL injection attacks are prevented."""
|
|
|
|
def test_login_sql_injection_in_email(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
|
"""Test that SQL injection in email field is prevented."""
|
|
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
# Create a legitimate user first
|
|
setup_test_user(mock_invoker, "legitimate@example.com", "TestPass123")
|
|
|
|
# Try SQL injection in email field
|
|
sql_injection_attempts = [
|
|
"' OR '1'='1",
|
|
"admin' --",
|
|
"' OR 1=1 --",
|
|
"'; DROP TABLE users; --",
|
|
"' UNION SELECT * FROM users --",
|
|
]
|
|
|
|
for injection_attempt in sql_injection_attempts:
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": injection_attempt,
|
|
"password": "TestPass123",
|
|
"remember_me": False,
|
|
},
|
|
)
|
|
|
|
# Should return 401 (invalid credentials) or 422 (validation error)
|
|
# Both are acceptable - the important thing is no SQL injection occurs
|
|
assert response.status_code in [401, 422], f"SQL injection attempt should be rejected: {injection_attempt}"
|
|
# Should NOT return 200 (success) or 500 (server error)
|
|
assert response.status_code != 200, f"SQL injection should not succeed: {injection_attempt}"
|
|
assert response.status_code != 500, f"SQL injection should not cause server error: {injection_attempt}"
|
|
|
|
def test_login_sql_injection_in_password(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
|
"""Test that SQL injection in password field is prevented."""
|
|
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
# Create a legitimate user
|
|
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
|
|
|
# Try SQL injection in password field
|
|
sql_injection_attempts = [
|
|
"' OR '1'='1",
|
|
"anything' OR '1'='1' --",
|
|
"' OR 1=1; DROP TABLE users; --",
|
|
]
|
|
|
|
for injection_attempt in sql_injection_attempts:
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": "test@example.com",
|
|
"password": injection_attempt,
|
|
"remember_me": False,
|
|
},
|
|
)
|
|
|
|
# Should fail authentication
|
|
assert response.status_code == 401, f"SQL injection attempt should be rejected: {injection_attempt}"
|
|
|
|
def test_user_service_sql_injection_in_email(self, mock_invoker: Invoker):
|
|
"""Test that user service prevents SQL injection in email lookups."""
|
|
user_service = mock_invoker.services.users
|
|
|
|
# Create a test user
|
|
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
|
|
|
# Try SQL injection in get_by_email
|
|
sql_injection_attempts = [
|
|
"test@example.com' OR '1'='1",
|
|
"' OR 1=1 --",
|
|
"test@example.com'; DROP TABLE users; --",
|
|
]
|
|
|
|
for injection_attempt in sql_injection_attempts:
|
|
# Should return None (not found), not raise an error or return wrong user
|
|
user = user_service.get_by_email(injection_attempt)
|
|
assert user is None, f"SQL injection should not return a user: {injection_attempt}"
|
|
|
|
|
|
class TestAuthorizationBypass:
|
|
"""Tests to ensure authorization cannot be bypassed."""
|
|
|
|
def test_cannot_access_protected_endpoint_without_token(self, client: TestClient):
|
|
"""Test that protected endpoints require authentication."""
|
|
# Try to access protected endpoint without token
|
|
response = client.get("/api/v1/auth/me")
|
|
|
|
assert response.status_code == 401
|
|
|
|
def test_cannot_access_protected_endpoint_with_invalid_token(
|
|
self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
|
):
|
|
"""Test that invalid tokens are rejected."""
|
|
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
invalid_tokens = [
|
|
"invalid_token",
|
|
"Bearer invalid_token",
|
|
"",
|
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature",
|
|
]
|
|
|
|
for token in invalid_tokens:
|
|
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
|
|
|
assert response.status_code == 401, f"Invalid token should be rejected: {token}"
|
|
|
|
def test_cannot_forge_admin_token(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
|
"""Test that admin privileges cannot be forged by modifying tokens."""
|
|
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
# Create a regular user and login
|
|
setup_test_user(mock_invoker, "regular@example.com", "TestPass123")
|
|
|
|
login_response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": "regular@example.com",
|
|
"password": "TestPass123",
|
|
"remember_me": False,
|
|
},
|
|
)
|
|
|
|
token = login_response.json()["token"]
|
|
|
|
# Try to modify the token to gain admin privileges
|
|
# (In practice, this should fail signature verification)
|
|
parts = token.split(".")
|
|
if len(parts) == 3:
|
|
# Decode the payload, modify it, and re-encode
|
|
import base64
|
|
import json
|
|
|
|
# Add padding if necessary
|
|
payload_b64 = parts[1]
|
|
padding = 4 - len(payload_b64) % 4
|
|
if padding != 4:
|
|
payload_b64 += "=" * padding
|
|
|
|
# Decode payload
|
|
try:
|
|
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
payload_data = json.loads(payload_bytes)
|
|
|
|
# Modify is_admin to true
|
|
payload_data["is_admin"] = True
|
|
|
|
# Re-encode
|
|
modified_payload_bytes = json.dumps(payload_data).encode()
|
|
modified_payload_b64 = base64.urlsafe_b64encode(modified_payload_bytes).decode().rstrip("=")
|
|
|
|
# Create forged token with modified payload but original signature
|
|
modified_token = f"{parts[0]}.{modified_payload_b64}.{parts[2]}"
|
|
|
|
# Attempt to use modified token
|
|
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {modified_token}"})
|
|
|
|
# Should be rejected (invalid signature)
|
|
assert response.status_code == 401
|
|
except Exception:
|
|
# If we can't decode/modify the token, that's fine - just skip this part of the test
|
|
pass
|
|
|
|
def test_regular_user_cannot_create_admin(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
|
"""Test that regular users cannot create admin users."""
|
|
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
# This test would require user management endpoints to be implemented
|
|
# For now, we test at the service level
|
|
user_service = mock_invoker.services.users
|
|
|
|
# Create a regular user
|
|
regular_user_data = UserCreateRequest(
|
|
email="regular@example.com",
|
|
display_name="Regular User",
|
|
password="TestPass123",
|
|
is_admin=False,
|
|
)
|
|
user_service.create(regular_user_data)
|
|
|
|
# Try to create an admin user (should only be possible through setup or by existing admin)
|
|
# The create_admin method checks if an admin already exists
|
|
admin_data = UserCreateRequest(
|
|
email="sneaky@example.com",
|
|
display_name="Sneaky Admin",
|
|
password="TestPass123",
|
|
)
|
|
|
|
# First create an actual admin
|
|
setup_test_admin(mock_invoker, "realadmin@example.com", "AdminPass123")
|
|
|
|
# Now trying to create another admin should fail
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
user_service.create_admin(admin_data)
|
|
|
|
|
|
class TestSessionSecurity:
|
|
"""Tests for session and token security."""
|
|
|
|
def test_token_expires_after_time(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
|
"""Test that tokens expire after their validity period."""
|
|
from datetime import timedelta
|
|
|
|
from invokeai.app.services.auth.token_service import TokenData, create_access_token
|
|
|
|
# Create a token that expires quickly
|
|
token_data = TokenData(
|
|
user_id="user123",
|
|
email="test@example.com",
|
|
is_admin=False,
|
|
)
|
|
|
|
# Create token with 10 millisecond expiration
|
|
expired_token = create_access_token(token_data, expires_delta=timedelta(milliseconds=10))
|
|
|
|
# Wait for expiration (wait longer than expiration time)
|
|
import time
|
|
|
|
time.sleep(0.02)
|
|
|
|
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
# Try to use expired token
|
|
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {expired_token}"})
|
|
|
|
assert response.status_code == 401
|
|
|
|
def test_logout_invalidates_session(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
|
"""Test that logout invalidates the session.
|
|
|
|
Note: Current implementation uses JWT which is stateless.
|
|
This test documents expected behavior for future server-side session tracking.
|
|
"""
|
|
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
# Create user and login
|
|
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
|
|
|
login_response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": "test@example.com",
|
|
"password": "TestPass123",
|
|
"remember_me": False,
|
|
},
|
|
)
|
|
|
|
token = login_response.json()["token"]
|
|
|
|
# Logout
|
|
logout_response = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"})
|
|
|
|
assert logout_response.status_code == 200
|
|
|
|
# Note: With JWT, the token is still technically valid until expiration
|
|
# For true session invalidation, server-side session tracking would be needed
|
|
|
|
|
|
class TestInputValidation:
|
|
"""Tests for input validation and sanitization."""
|
|
|
|
def test_email_validation_on_login(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
|
"""Test that email validation is enforced on login."""
|
|
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
# Invalid email formats should be rejected by pydantic validation
|
|
invalid_emails = [
|
|
"not_an_email",
|
|
"@example.com",
|
|
"user@",
|
|
"user @example.com", # space in email
|
|
"../../../etc/passwd", # path traversal attempt
|
|
]
|
|
|
|
for invalid_email in invalid_emails:
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": invalid_email,
|
|
"password": "TestPass123",
|
|
"remember_me": False,
|
|
},
|
|
)
|
|
|
|
# Should return 422 (validation error) or 401 (invalid credentials)
|
|
assert response.status_code in [401, 422], f"Invalid email should be rejected: {invalid_email}"
|
|
|
|
def test_xss_prevention_in_user_data(self, mock_invoker: Invoker):
|
|
"""Test that XSS attempts in user data are handled safely.
|
|
|
|
Note: Database storage uses parameterized queries which prevent XSS.
|
|
This test ensures data is stored and retrieved without executing scripts.
|
|
"""
|
|
user_service = mock_invoker.services.users
|
|
|
|
# Try to create user with XSS payload in display name
|
|
xss_payloads = [
|
|
"<script>alert('xss')</script>",
|
|
"'; alert('xss'); //",
|
|
"<img src=x onerror=alert('xss')>",
|
|
]
|
|
|
|
for payload in xss_payloads:
|
|
user_data = UserCreateRequest(
|
|
email=f"xss{hash(payload)}@example.com", # unique email
|
|
display_name=payload,
|
|
password="TestPass123",
|
|
is_admin=False,
|
|
)
|
|
|
|
# Should not raise an error - data is stored as-is
|
|
user = user_service.create(user_data)
|
|
|
|
# Verify data is stored exactly as provided (not executed or modified)
|
|
assert user.display_name == payload
|
|
|
|
# Cleanup
|
|
user_service.delete(user.user_id)
|
|
|
|
def test_path_traversal_prevention(self, mock_invoker: Invoker):
|
|
"""Test that path traversal attempts in user input are handled."""
|
|
user_service = mock_invoker.services.users
|
|
|
|
# Path traversal attempts
|
|
path_traversal_attempts = [
|
|
"../../../etc/passwd",
|
|
"..\\..\\..\\windows\\system32",
|
|
"user/../../../secret",
|
|
]
|
|
|
|
for attempt in path_traversal_attempts:
|
|
# These should be stored as literal strings, not interpreted as paths
|
|
user_data = UserCreateRequest(
|
|
email=f"path{hash(attempt)}@example.com",
|
|
display_name=attempt,
|
|
password="TestPass123",
|
|
is_admin=False,
|
|
)
|
|
|
|
user = user_service.create(user_data)
|
|
assert user.display_name == attempt
|
|
|
|
# Cleanup
|
|
user_service.delete(user.user_id)
|
|
|
|
|
|
class TestRateLimiting:
|
|
"""Tests for rate limiting and brute force protection.
|
|
|
|
Note: Rate limiting is not currently implemented in the codebase.
|
|
These tests document expected behavior for future implementation.
|
|
"""
|
|
|
|
@pytest.mark.skip(reason="Rate limiting not yet implemented")
|
|
def test_login_rate_limiting(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
|
"""Test that excessive login attempts are rate limited."""
|
|
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
|
|
|
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
|
|
|
# Try many login attempts with wrong password
|
|
for i in range(20):
|
|
response = client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": "test@example.com",
|
|
"password": "WrongPassword",
|
|
"remember_me": False,
|
|
},
|
|
)
|
|
|
|
if i < 10:
|
|
# First attempts should return 401
|
|
assert response.status_code == 401
|
|
else:
|
|
# After many attempts, should be rate limited (429)
|
|
# This is expected behavior for future implementation
|
|
pass
|