Harden auth, redaction, upload size checks, and compose token requirements
This commit is contained in:
273
backend/tests/test_security_controls.py
Normal file
273
backend/tests/test_security_controls.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Unit coverage for API auth, SSRF validation, and processing-log redaction controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
if "pydantic_settings" not in sys.modules:
|
||||
pydantic_settings_stub = ModuleType("pydantic_settings")
|
||||
|
||||
class _BaseSettings:
|
||||
"""Minimal BaseSettings replacement for dependency-light unit test execution."""
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def _settings_config_dict(**kwargs: object) -> dict[str, object]:
|
||||
"""Returns configuration values using dict semantics expected by settings module."""
|
||||
|
||||
return kwargs
|
||||
|
||||
pydantic_settings_stub.BaseSettings = _BaseSettings
|
||||
pydantic_settings_stub.SettingsConfigDict = _settings_config_dict
|
||||
sys.modules["pydantic_settings"] = pydantic_settings_stub
|
||||
|
||||
if "fastapi" not in sys.modules:
|
||||
fastapi_stub = ModuleType("fastapi")
|
||||
|
||||
class _HTTPException(Exception):
|
||||
"""Minimal HTTPException compatible with route dependency tests."""
|
||||
|
||||
def __init__(self, status_code: int, detail: str, headers: dict[str, str] | None = None) -> None:
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
self.headers = headers or {}
|
||||
|
||||
class _Status:
|
||||
"""Minimal status namespace for auth unit tests."""
|
||||
|
||||
HTTP_401_UNAUTHORIZED = 401
|
||||
HTTP_403_FORBIDDEN = 403
|
||||
HTTP_503_SERVICE_UNAVAILABLE = 503
|
||||
|
||||
def _depends(dependency): # type: ignore[no-untyped-def]
|
||||
"""Returns provided dependency unchanged for unit testing."""
|
||||
|
||||
return dependency
|
||||
|
||||
fastapi_stub.Depends = _depends
|
||||
fastapi_stub.HTTPException = _HTTPException
|
||||
fastapi_stub.status = _Status()
|
||||
sys.modules["fastapi"] = fastapi_stub
|
||||
|
||||
if "fastapi.security" not in sys.modules:
|
||||
fastapi_security_stub = ModuleType("fastapi.security")
|
||||
|
||||
class _HTTPAuthorizationCredentials:
|
||||
"""Minimal bearer credential object used by auth dependency tests."""
|
||||
|
||||
def __init__(self, *, scheme: str, credentials: str) -> None:
|
||||
self.scheme = scheme
|
||||
self.credentials = credentials
|
||||
|
||||
class _HTTPBearer:
|
||||
"""Minimal HTTPBearer stand-in for dependency construction."""
|
||||
|
||||
def __init__(self, auto_error: bool = True) -> None:
|
||||
self.auto_error = auto_error
|
||||
|
||||
fastapi_security_stub.HTTPAuthorizationCredentials = _HTTPAuthorizationCredentials
|
||||
fastapi_security_stub.HTTPBearer = _HTTPBearer
|
||||
sys.modules["fastapi.security"] = fastapi_security_stub
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from app.api.auth import AuthRole, get_request_role, require_admin
|
||||
from app.core import config as config_module
|
||||
from app.models.processing_log import sanitize_processing_log_payload_value, sanitize_processing_log_text
|
||||
from app.schemas.processing_logs import ProcessingLogEntryResponse
|
||||
|
||||
|
||||
def _security_settings(
|
||||
*,
|
||||
allowlist: list[str] | None = None,
|
||||
allow_http: bool = False,
|
||||
allow_private_network: bool = False,
|
||||
) -> SimpleNamespace:
|
||||
"""Builds lightweight settings object for provider URL validation tests."""
|
||||
|
||||
return SimpleNamespace(
|
||||
provider_base_url_allowlist=allowlist if allowlist is not None else ["api.openai.com"],
|
||||
provider_base_url_allow_http=allow_http,
|
||||
provider_base_url_allow_private_network=allow_private_network,
|
||||
)
|
||||
|
||||
|
||||
class AuthDependencyTests(unittest.TestCase):
|
||||
"""Verifies token authentication and admin authorization behavior."""
|
||||
|
||||
def test_get_request_role_accepts_admin_token(self) -> None:
|
||||
"""Admin token resolves admin role."""
|
||||
|
||||
settings = SimpleNamespace(admin_api_token="admin-token", user_api_token="user-token")
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="admin-token")
|
||||
role = get_request_role(credentials=credentials, settings=settings)
|
||||
self.assertEqual(role, AuthRole.ADMIN)
|
||||
|
||||
def test_get_request_role_rejects_missing_credentials(self) -> None:
|
||||
"""Missing bearer credentials return 401."""
|
||||
|
||||
settings = SimpleNamespace(admin_api_token="admin-token", user_api_token="user-token")
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
get_request_role(credentials=None, settings=settings)
|
||||
self.assertEqual(context.exception.status_code, 401)
|
||||
|
||||
def test_require_admin_rejects_user_role(self) -> None:
|
||||
"""User role cannot access admin-only endpoints."""
|
||||
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
require_admin(role=AuthRole.USER)
|
||||
self.assertEqual(context.exception.status_code, 403)
|
||||
|
||||
|
||||
class ProviderBaseUrlValidationTests(unittest.TestCase):
|
||||
"""Verifies allowlist, scheme, and private-network SSRF protections."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Clears URL validation cache to keep tests independent."""
|
||||
|
||||
config_module._normalize_and_validate_provider_base_url_cached.cache_clear()
|
||||
|
||||
def test_validation_accepts_allowlisted_https_url(self) -> None:
|
||||
"""Allowlisted HTTPS URLs are normalized with /v1 suffix."""
|
||||
|
||||
with patch.object(config_module, "get_settings", return_value=_security_settings(allowlist=["api.openai.com"])):
|
||||
normalized = config_module.normalize_and_validate_provider_base_url("https://api.openai.com")
|
||||
self.assertEqual(normalized, "https://api.openai.com/v1")
|
||||
|
||||
def test_validation_rejects_non_allowlisted_host(self) -> None:
|
||||
"""Hosts outside configured allowlist are rejected."""
|
||||
|
||||
with patch.object(config_module, "get_settings", return_value=_security_settings(allowlist=["api.openai.com"])):
|
||||
with self.assertRaises(ValueError):
|
||||
config_module.normalize_and_validate_provider_base_url("https://example.org/v1")
|
||||
|
||||
def test_validation_rejects_private_ip_literal(self) -> None:
|
||||
"""Private and loopback IP literals are blocked."""
|
||||
|
||||
with patch.object(config_module, "get_settings", return_value=_security_settings(allowlist=[])):
|
||||
with self.assertRaises(ValueError):
|
||||
config_module.normalize_and_validate_provider_base_url("https://127.0.0.1/v1")
|
||||
|
||||
def test_validation_rejects_private_ip_after_dns_resolution(self) -> None:
|
||||
"""DNS rebind protection blocks public hostnames resolving to private addresses."""
|
||||
|
||||
mocked_dns_response = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("127.0.0.1", 443)),
|
||||
]
|
||||
with (
|
||||
patch.object(config_module, "get_settings", return_value=_security_settings(allowlist=["api.openai.com"])),
|
||||
patch.object(config_module.socket, "getaddrinfo", return_value=mocked_dns_response),
|
||||
):
|
||||
with self.assertRaises(ValueError):
|
||||
config_module.normalize_and_validate_provider_base_url(
|
||||
"https://api.openai.com/v1",
|
||||
resolve_dns=True,
|
||||
)
|
||||
|
||||
def test_resolve_dns_validation_revalidates_each_call(self) -> None:
|
||||
"""DNS-resolved validation is not cached and re-checks host resolution each call."""
|
||||
|
||||
mocked_dns_response = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("8.8.8.8", 443)),
|
||||
]
|
||||
with (
|
||||
patch.object(config_module, "get_settings", return_value=_security_settings(allowlist=["api.openai.com"])),
|
||||
patch.object(config_module.socket, "getaddrinfo", return_value=mocked_dns_response) as getaddrinfo_mock,
|
||||
):
|
||||
first = config_module.normalize_and_validate_provider_base_url(
|
||||
"https://api.openai.com/v1",
|
||||
resolve_dns=True,
|
||||
)
|
||||
second = config_module.normalize_and_validate_provider_base_url(
|
||||
"https://api.openai.com/v1",
|
||||
resolve_dns=True,
|
||||
)
|
||||
self.assertEqual(first, "https://api.openai.com/v1")
|
||||
self.assertEqual(second, "https://api.openai.com/v1")
|
||||
self.assertEqual(getaddrinfo_mock.call_count, 2)
|
||||
|
||||
|
||||
class ProcessingLogRedactionTests(unittest.TestCase):
|
||||
"""Verifies sensitive processing-log values are redacted for persistence and responses."""
|
||||
|
||||
def test_payload_redacts_sensitive_keys(self) -> None:
|
||||
"""Sensitive payload keys are replaced with redaction marker."""
|
||||
|
||||
sanitized = sanitize_processing_log_payload_value(
|
||||
{
|
||||
"api_key": "secret-value",
|
||||
"nested": {
|
||||
"authorization": "Bearer sample-token",
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertEqual(sanitized["api_key"], "[REDACTED]")
|
||||
self.assertEqual(sanitized["nested"]["authorization"], "[REDACTED]")
|
||||
|
||||
def test_text_redaction_removes_bearer_and_jwt_values(self) -> None:
|
||||
"""Bearer and JWT token substrings are fully removed from log text."""
|
||||
|
||||
bearer_token = "super-secret-token-123"
|
||||
jwt_token = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
|
||||
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ."
|
||||
"signaturevalue123456789"
|
||||
)
|
||||
sanitized = sanitize_processing_log_text(
|
||||
f"Authorization: Bearer {bearer_token}\nraw_jwt={jwt_token}"
|
||||
)
|
||||
self.assertIsNotNone(sanitized)
|
||||
sanitized_text = sanitized or ""
|
||||
self.assertIn("[REDACTED]", sanitized_text)
|
||||
self.assertNotIn(bearer_token, sanitized_text)
|
||||
self.assertNotIn(jwt_token, sanitized_text)
|
||||
|
||||
def test_response_schema_applies_redaction_to_existing_entries(self) -> None:
|
||||
"""API schema validators redact sensitive fields from legacy stored rows."""
|
||||
|
||||
bearer_token = "abc123token"
|
||||
jwt_token = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
|
||||
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ."
|
||||
"signaturevalue123456789"
|
||||
)
|
||||
response = ProcessingLogEntryResponse.model_validate(
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": datetime.now(UTC),
|
||||
"level": "info",
|
||||
"stage": "summary",
|
||||
"event": "response",
|
||||
"document_id": None,
|
||||
"document_filename": "sample.txt",
|
||||
"provider_id": "provider",
|
||||
"model_name": "model",
|
||||
"prompt_text": f"Authorization: Bearer {bearer_token}",
|
||||
"response_text": f"token={jwt_token}",
|
||||
"payload_json": {"password": "secret", "trace_id": "trace-1"},
|
||||
}
|
||||
)
|
||||
self.assertEqual(response.payload_json["password"], "[REDACTED]")
|
||||
self.assertIn("[REDACTED]", response.prompt_text or "")
|
||||
self.assertIn("[REDACTED]", response.response_text or "")
|
||||
self.assertNotIn(bearer_token, response.prompt_text or "")
|
||||
self.assertNotIn(jwt_token, response.response_text or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user