feat: harden gateway with policy engine, secure tools, and governance docs

This commit is contained in:
2026-02-14 16:05:56 +01:00
parent e17d34e6d7
commit 5969892af3
55 changed files with 4711 additions and 1587 deletions

View File

@@ -1,10 +1,11 @@
"""Authentication module for MCP server API key validation."""
from __future__ import annotations
import hashlib
import hmac
import secrets
from datetime import datetime, timezone
from typing import Optional, Tuple
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings
@@ -13,70 +14,43 @@ from aegis_gitea_mcp.config import get_settings
class AuthenticationError(Exception):
"""Raised when authentication fails."""
pass
class APIKeyValidator:
"""Validates API keys for MCP server access."""
"""Validate API keys for MCP server access."""
def __init__(self) -> None:
"""Initialize API key validator."""
"""Initialize API key validator state."""
self.settings = get_settings()
self.audit = get_audit_logger()
self._failed_attempts: dict[str, list[datetime]] = {}
def _constant_time_compare(self, a: str, b: str) -> bool:
"""Compare two strings in constant time to prevent timing attacks.
Args:
a: First string
b: Second string
Returns:
True if strings are equal, False otherwise
"""
return hmac.compare_digest(a, b)
def _constant_time_compare(self, candidate: str, expected: str) -> bool:
"""Compare API keys in constant time to mitigate timing attacks."""
return hmac.compare_digest(candidate, expected)
def _check_rate_limit(self, identifier: str) -> bool:
"""Check if identifier has exceeded failed authentication rate limit.
Args:
identifier: IP address or other identifier
Returns:
True if within rate limit, False if exceeded
"""
"""Check whether authentication failures exceed configured threshold."""
now = datetime.now(timezone.utc)
window_start = now.timestamp() - self.settings.auth_failure_window
boundary = now.timestamp() - self.settings.auth_failure_window
# Clean up old attempts
if identifier in self._failed_attempts:
self._failed_attempts[identifier] = [
attempt
for attempt in self._failed_attempts[identifier]
if attempt.timestamp() > window_start
if attempt.timestamp() > boundary
]
# Check count
attempt_count = len(self._failed_attempts.get(identifier, []))
return attempt_count < self.settings.max_auth_failures
return len(self._failed_attempts.get(identifier, [])) < self.settings.max_auth_failures
def _record_failed_attempt(self, identifier: str) -> None:
"""Record a failed authentication attempt.
"""Record a failed authentication attempt for rate limiting."""
attempt_time = datetime.now(timezone.utc)
self._failed_attempts.setdefault(identifier, []).append(attempt_time)
Args:
identifier: IP address or other identifier
"""
now = datetime.now(timezone.utc)
if identifier not in self._failed_attempts:
self._failed_attempts[identifier] = []
self._failed_attempts[identifier].append(now)
# Check if threshold exceeded
if len(self._failed_attempts[identifier]) >= self.settings.max_auth_failures:
self.audit.log_security_event(
event_type="auth_rate_limit_exceeded",
description=f"IP {identifier} exceeded auth failure threshold",
description="Authentication failure threshold exceeded",
severity="high",
metadata={
"identifier": identifier,
@@ -86,29 +60,31 @@ class APIKeyValidator:
)
def validate_api_key(
self, provided_key: Optional[str], client_ip: str, user_agent: str
) -> Tuple[bool, Optional[str]]:
self,
provided_key: str | None,
client_ip: str,
user_agent: str,
) -> tuple[bool, str | None]:
"""Validate an API key.
Args:
provided_key: API key provided by client
client_ip: Client IP address
user_agent: Client user agent string
provided_key: API key provided by client.
client_ip: Request source IP address.
user_agent: Request user agent.
Returns:
Tuple of (is_valid, error_message)
Tuple of `(is_valid, error_message)`.
"""
# Check if authentication is enabled
if not self.settings.auth_enabled:
# Security note: auth-disabled mode is explicit and should be monitored.
self.audit.log_security_event(
event_type="auth_disabled",
description="Authentication is disabled - allowing all requests",
description="Authentication disabled; request was allowed",
severity="critical",
metadata={"client_ip": client_ip},
)
return True, None
# Check rate limit
if not self._check_rate_limit(client_ip):
self.audit.log_access_denied(
tool_name="api_authentication",
@@ -116,7 +92,6 @@ class APIKeyValidator:
)
return False, "Too many failed authentication attempts. Please try again later."
# Check if key was provided
if not provided_key:
self._record_failed_attempt(client_ip)
self.audit.log_access_denied(
@@ -125,8 +100,8 @@ class APIKeyValidator:
)
return False, "Authorization header missing. Required: Authorization: Bearer <api-key>"
# Validate key format (should be at least 32 characters)
if len(provided_key) < 32:
# Validation logic: reject short keys early to reduce brute force surface.
self._record_failed_attempt(client_ip)
self.audit.log_access_denied(
tool_name="api_authentication",
@@ -134,99 +109,87 @@ class APIKeyValidator:
)
return False, "Invalid API key format"
# Get valid API keys from config
valid_keys = self.settings.mcp_api_keys
if not valid_keys:
self.audit.log_security_event(
event_type="no_api_keys_configured",
description="No API keys configured in environment",
description="No API keys configured while auth is enabled",
severity="critical",
metadata={"client_ip": client_ip},
)
return False, "Server configuration error: No API keys configured"
# Check against all valid keys (constant time comparison)
is_valid = any(self._constant_time_compare(provided_key, valid_key) for valid_key in valid_keys)
is_valid = any(
self._constant_time_compare(provided_key, valid_key) for valid_key in valid_keys
)
if is_valid:
# Success - log and return
key_hint = f"{provided_key[:8]}...{provided_key[-4:]}"
key_fingerprint = hashlib.sha256(provided_key.encode("utf-8")).hexdigest()[:12]
self.audit.log_tool_invocation(
tool_name="api_authentication",
result_status="success",
params={"client_ip": client_ip, "user_agent": user_agent, "key_hint": key_hint},
)
return True, None
else:
# Failure - record attempt and log
self._record_failed_attempt(client_ip)
key_hint = f"{provided_key[:8]}..." if len(provided_key) >= 8 else "too_short"
self.audit.log_access_denied(
tool_name="api_authentication",
reason="invalid_api_key",
)
self.audit.log_security_event(
event_type="invalid_api_key_attempt",
description=f"Invalid API key attempted from {client_ip}",
severity="medium",
metadata={
params={
"client_ip": client_ip,
"user_agent": user_agent,
"key_hint": key_hint,
"key_fingerprint": key_fingerprint,
},
)
return False, "Invalid API key"
return True, None
def extract_bearer_token(self, authorization_header: Optional[str]) -> Optional[str]:
"""Extract bearer token from Authorization header.
self._record_failed_attempt(client_ip)
self.audit.log_access_denied(
tool_name="api_authentication",
reason="invalid_api_key",
)
self.audit.log_security_event(
event_type="invalid_api_key_attempt",
description="Invalid API key was presented",
severity="medium",
metadata={"client_ip": client_ip, "user_agent": user_agent},
)
return False, "Invalid API key"
Args:
authorization_header: Authorization header value
def extract_bearer_token(self, authorization_header: str | None) -> str | None:
"""Extract API token from `Authorization: Bearer <token>` header.
Returns:
Extracted token or None if invalid format
Security note:
The scheme is case-sensitive by policy (`Bearer`) to prevent accepting
ambiguous client implementations and to align strict API contracts.
"""
if not authorization_header:
return None
parts = authorization_header.split()
parts = authorization_header.split(" ")
if len(parts) != 2:
return None
scheme, token = parts
if scheme.lower() != "bearer":
if scheme != "Bearer":
return None
if not token.strip():
return None
return token
return token.strip()
def generate_api_key(length: int = 64) -> str:
"""Generate a cryptographically secure API key.
Args:
length: Length of the key in characters (default: 64)
length: Length of key in characters.
Returns:
Generated API key as hex string
Generated API key string.
"""
return secrets.token_hex(length // 2)
def hash_api_key(api_key: str) -> str:
"""Hash an API key for secure storage (future use).
Args:
api_key: Plain text API key
Returns:
SHA256 hash of the key
"""
return hashlib.sha256(api_key.encode()).hexdigest()
"""Hash an API key for secure storage and comparison."""
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
# Global validator instance
_validator: Optional[APIKeyValidator] = None
_validator: APIKeyValidator | None = None
def get_validator() -> APIKeyValidator:
@@ -238,6 +201,6 @@ def get_validator() -> APIKeyValidator:
def reset_validator() -> None:
"""Reset global validator instance (primarily for testing)."""
"""Reset global API key validator instance (primarily for testing)."""
global _validator
_validator = None