diff --git a/backend/main.py b/backend/main.py index 389f7f1..6f5a2af 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,8 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pathlib import Path +from middleware import SDKProtectionMiddleware + from original_service import router as original_router from learning_units_api import router as learning_units_router from frontend.studio import router as studio_router @@ -60,6 +62,9 @@ app.add_middleware( allow_headers=["*"], ) +# SDK Protection Middleware (Schutz vor systematischer Enumeration) +app.add_middleware(SDKProtectionMiddleware) + # Hier hängen wir die einzelnen Service-Router ein. # Alle Routen bekommen das Präfix /api, damit das Frontend sie findet. app.include_router(original_router, prefix="/api") diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py index 1497144..ff94c49 100644 --- a/backend/middleware/__init__.py +++ b/backend/middleware/__init__.py @@ -7,6 +7,7 @@ This module provides middleware components for the FastAPI backend: - Rate Limiter: Protects against abuse (Valkey-based) - PII Redactor: Redacts sensitive data from logs - Input Gate: Validates request body size and content types +- SDK Protection: Protects SDK endpoints from systematic enumeration """ from .request_id import RequestIDMiddleware, get_request_id @@ -14,6 +15,7 @@ from .security_headers import SecurityHeadersMiddleware from .rate_limiter import RateLimiterMiddleware from .pii_redactor import PIIRedactor, redact_pii from .input_gate import InputGateMiddleware +from .sdk_protection import SDKProtectionMiddleware, SDKProtectionConfig __all__ = [ "RequestIDMiddleware", @@ -23,4 +25,6 @@ __all__ = [ "PIIRedactor", "redact_pii", "InputGateMiddleware", + "SDKProtectionMiddleware", + "SDKProtectionConfig", ] diff --git a/backend/middleware/sdk_protection.py b/backend/middleware/sdk_protection.py new file mode 100644 index 0000000..3dfdda7 --- /dev/null +++ b/backend/middleware/sdk_protection.py @@ -0,0 +1,754 @@ +""" +SDK Protection Middleware + +Schuetzt SDK-Endpunkte vor systematischer Enumeration durch +Wettbewerber oder automatisierte Crawler. Erkennt Anomalien +wie sequentielle Abfragen, hohe Kategorie-Diversitaet und +Burst-Zugriffe auf gleiche Kategorien. + +Features: +- Multi-Window Quota (Minute, Stunde, Tag, Monat) +- Anomaly-Score mit progressivem Throttling +- Diversity-Tracking (max verschiedene Kategorien/Stunde) +- Burst-Detection (gleiche Kategorie in kurzem Zeitraum) +- Sequential-Enumeration-Detection (alphabetisch sortierte Zugriffe) +- Unusual-Hours Detection (0-5 Uhr UTC) +- Multi-Tenant Detection (>3 Tenants/Stunde) +- HMAC-basiertes Watermarking (X-BP-Trace) +- Graceful Fallback auf In-Memory wenn Valkey nicht verfuegbar + +Usage: + from middleware import SDKProtectionMiddleware + + app.add_middleware(SDKProtectionMiddleware) +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import logging +import os +import random +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Set, Tuple + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +try: + import redis.asyncio as redis + REDIS_AVAILABLE = True +except ImportError: + REDIS_AVAILABLE = False + redis = None + +logger = logging.getLogger("sdk_protection") + + +# ============================================== +# Configuration +# ============================================== + + +@dataclass +class QuotaTier: + """Rate limits for a specific tier.""" + name: str + per_minute: int + per_hour: int + per_day: int + per_month: int + + +DEFAULT_TIERS: Dict[str, QuotaTier] = { + "free": QuotaTier("free", 30, 500, 3_000, 50_000), + "standard": QuotaTier("standard", 60, 1_500, 10_000, 200_000), + "enterprise": QuotaTier("enterprise", 120, 5_000, 50_000, 1_000_000), +} + + +@dataclass +class SDKProtectionConfig: + """Configuration for SDK protection middleware.""" + + # Valkey connection + valkey_url: str = "redis://localhost:6379" + + # Quota tiers + tiers: Dict[str, QuotaTier] = field(default_factory=lambda: dict(DEFAULT_TIERS)) + default_tier: str = "free" + + # Diversity threshold: max unique categories per hour + diversity_threshold: int = 40 + + # Burst threshold: max requests to same category in 2 minutes + burst_threshold: int = 15 + burst_window: int = 120 # seconds + + # Sequential detection: min sorted entries in 5 minutes + sequential_window: int = 300 # seconds + sequential_min_entries: int = 10 + sequential_sorted_ratio: float = 0.7 # 70% sorted = suspicious + + # Throttle levels based on anomaly score + throttle_level_1_score: int = 30 # delay 1-3s + throttle_level_2_score: int = 60 # delay 5-10s, reduced detail + throttle_level_3_score: int = 85 # block (429) + + # Score decay: multiply by factor every interval + score_decay_factor: float = 0.95 + score_decay_interval: int = 300 # 5 minutes + + # Score increments for anomalies + score_diversity_increment: int = 15 + score_burst_increment: int = 20 + score_sequential_increment: int = 25 + score_unusual_hours_increment: int = 10 + score_multi_tenant_increment: int = 15 + + # Multi-tenant threshold + multi_tenant_threshold: int = 3 + + # Unusual hours (UTC) + unusual_hours_start: int = 0 + unusual_hours_end: int = 5 + + # Watermark secret for HMAC + watermark_secret: str = "" + + # Protected path prefixes + protected_paths: List[str] = field(default_factory=lambda: [ + "/api/sdk/", + "/api/compliance/", + "/api/v1/tom/", + "/api/v1/dsfa/", + "/api/v1/vvt/", + "/api/v1/controls/", + "/api/v1/assessment/", + "/api/v1/eh/", + "/api/v1/namespace/", + ]) + + # Excluded paths (never protected) + excluded_paths: List[str] = field(default_factory=lambda: [ + "/health", + "/metrics", + "/api/health", + ]) + + # Fallback to in-memory when Valkey unavailable + fallback_enabled: bool = True + + # Key prefix + key_prefix: str = "sdk_protect" + + +# ============================================== +# Category Map +# ============================================== + + +CATEGORY_MAP: Dict[str, str] = { + # TOM categories + "/api/v1/tom/access-control": "tom_access_control", + "/api/v1/tom/encryption": "tom_encryption", + "/api/v1/tom/pseudonymization": "tom_pseudonymization", + "/api/v1/tom/integrity": "tom_integrity", + "/api/v1/tom/availability": "tom_availability", + "/api/v1/tom/resilience": "tom_resilience", + "/api/v1/tom/recoverability": "tom_recoverability", + "/api/v1/tom/testing": "tom_testing", + "/api/v1/tom/data-separation": "tom_data_separation", + "/api/v1/tom/input-control": "tom_input_control", + "/api/v1/tom/transport-control": "tom_transport_control", + "/api/v1/tom/output-control": "tom_output_control", + "/api/v1/tom/order-control": "tom_order_control", + # DSFA categories + "/api/v1/dsfa/threshold": "dsfa_threshold", + "/api/v1/dsfa/necessity": "dsfa_necessity", + "/api/v1/dsfa/risks": "dsfa_risks", + "/api/v1/dsfa/measures": "dsfa_measures", + "/api/v1/dsfa/residual": "dsfa_residual", + "/api/v1/dsfa/consultation": "dsfa_consultation", + # VVT categories + "/api/v1/vvt/processing": "vvt_processing", + "/api/v1/vvt/purposes": "vvt_purposes", + "/api/v1/vvt/categories": "vvt_categories", + "/api/v1/vvt/recipients": "vvt_recipients", + "/api/v1/vvt/transfers": "vvt_transfers", + "/api/v1/vvt/retention": "vvt_retention", + "/api/v1/vvt/security": "vvt_security", + # Controls + "/api/v1/controls/": "controls_general", + # Assessment + "/api/v1/assessment/": "assessment_general", + # SDK + "/api/sdk/": "sdk_general", + # Compliance + "/api/compliance/": "compliance_general", + # EH + "/api/v1/eh/": "eh_general", + # Namespace + "/api/v1/namespace/": "namespace_general", +} + + +def _extract_category(path: str) -> str: + """Extract category from request path using longest-prefix match.""" + best_match = "" + best_category = "unknown" + for prefix, category in CATEGORY_MAP.items(): + if path.startswith(prefix) and len(prefix) > len(best_match): + best_match = prefix + best_category = category + return best_category + + +# ============================================== +# In-Memory Fallback +# ============================================== + + +class InMemorySDKProtection: + """Fallback in-memory tracking when Valkey is unavailable.""" + + def __init__(self): + self._quotas: Dict[str, List[float]] = {} + self._diversity: Dict[str, Set[str]] = {} + self._bursts: Dict[str, List[float]] = {} + self._sequences: Dict[str, List[str]] = {} + self._scores: Dict[str, Dict[str, Any]] = {} + self._tenants: Dict[str, Set[str]] = {} + self._lock = asyncio.Lock() + + async def check_quota(self, user: str, tier: QuotaTier) -> Tuple[bool, Dict[str, int]]: + """Check multi-window quotas. Returns (allowed, remaining_per_window).""" + async with self._lock: + now = time.time() + remaining = {} + windows = { + "minute": (60, tier.per_minute), + "hour": (3600, tier.per_hour), + "day": (86400, tier.per_day), + "month": (2592000, tier.per_month), + } + for window_name, (window_size, limit) in windows.items(): + key = f"{user}:{window_name}" + if key not in self._quotas: + self._quotas[key] = [] + self._quotas[key] = [t for t in self._quotas[key] if t > now - window_size] + count = len(self._quotas[key]) + remaining[window_name] = max(0, limit - count) + if count >= limit: + return False, remaining + + # Record the request in all windows + for window_name in windows: + key = f"{user}:{window_name}" + self._quotas[key].append(now) + + return True, remaining + + async def track_diversity(self, user: str, category: str, hour_key: str) -> int: + """Track category diversity. Returns current unique count.""" + async with self._lock: + key = f"{user}:{hour_key}" + if key not in self._diversity: + self._diversity[key] = set() + self._diversity[key].add(category) + return len(self._diversity[key]) + + async def track_burst(self, user: str, category: str, window: int) -> int: + """Track burst access to same category. Returns count in window.""" + async with self._lock: + now = time.time() + key = f"{user}:{category}" + if key not in self._bursts: + self._bursts[key] = [] + self._bursts[key] = [t for t in self._bursts[key] if t > now - window] + self._bursts[key].append(now) + return len(self._bursts[key]) + + async def track_sequence(self, user: str, category: str, max_len: int = 10) -> List[str]: + """Track category sequence. Returns last N categories.""" + async with self._lock: + if user not in self._sequences: + self._sequences[user] = [] + self._sequences[user].append(category) + if len(self._sequences[user]) > max_len: + self._sequences[user] = self._sequences[user][-max_len:] + return list(self._sequences[user]) + + async def get_score(self, user: str) -> Tuple[float, float]: + """Get anomaly score and last decay timestamp.""" + async with self._lock: + if user not in self._scores: + self._scores[user] = {"score": 0.0, "last_decay": time.time()} + return self._scores[user]["score"], self._scores[user]["last_decay"] + + async def set_score(self, user: str, score: float, last_decay: float): + """Set anomaly score.""" + async with self._lock: + self._scores[user] = {"score": score, "last_decay": last_decay} + + async def track_tenant(self, user: str, tenant_id: str, hour_key: str) -> int: + """Track tenant access. Returns unique tenant count.""" + async with self._lock: + key = f"{user}:{hour_key}" + if key not in self._tenants: + self._tenants[key] = set() + self._tenants[key].add(tenant_id) + return len(self._tenants[key]) + + +# ============================================== +# SDK Protection Middleware +# ============================================== + + +class SDKProtectionMiddleware(BaseHTTPMiddleware): + """ + Middleware to protect SDK endpoints from systematic enumeration. + + Tracks anomaly scores and applies progressive throttling based on + detected patterns like sequential access, burst requests, and + high category diversity. + """ + + def __init__( + self, + app, + config: Optional[SDKProtectionConfig] = None, + ): + super().__init__(app) + self.config = config or SDKProtectionConfig() + + # Auto-configure from environment + self.config.valkey_url = os.getenv("VALKEY_URL", self.config.valkey_url) + self.config.watermark_secret = os.getenv( + "SDK_WATERMARK_SECRET", + self.config.watermark_secret or "bp-sdk-watermark-default" + ) + + self._redis: Optional[redis.Redis] = None + self._fallback = InMemorySDKProtection() + self._valkey_available = False + + async def _get_redis(self) -> Optional[redis.Redis]: + """Get or create Redis/Valkey connection.""" + if not REDIS_AVAILABLE: + return None + + if self._redis is None: + try: + self._redis = redis.from_url( + self.config.valkey_url, + decode_responses=True, + socket_timeout=1.0, + socket_connect_timeout=1.0, + ) + await self._redis.ping() + self._valkey_available = True + except Exception: + self._valkey_available = False + self._redis = None + + return self._redis + + def _is_protected_path(self, path: str) -> bool: + """Check if the path is protected by SDK middleware.""" + if path in self.config.excluded_paths: + return False + return any(path.startswith(p) for p in self.config.protected_paths) + + def _get_user_id(self, request: Request) -> Optional[str]: + """Extract user ID from request.""" + if hasattr(request.state, "session") and request.state.session: + return getattr(request.state.session, "user_id", None) + api_key = request.headers.get("X-API-Key") + if api_key: + return f"apikey:{hashlib.sha256(api_key.encode()).hexdigest()[:16]}" + return None + + def _get_user_tier(self, request: Request) -> str: + """Determine user's tier from request.""" + if hasattr(request.state, "tier"): + return request.state.tier + tier_header = request.headers.get("X-SDK-Tier") + if tier_header and tier_header in self.config.tiers: + return tier_header + return self.config.default_tier + + def _get_tenant_id(self, request: Request) -> Optional[str]: + """Extract tenant ID from request.""" + return request.headers.get("X-Tenant-ID") + + def _generate_watermark(self, user_id: str, timestamp: float) -> str: + """Generate HMAC-based watermark for response tracing.""" + message = f"{user_id}:{timestamp:.0f}" + return hmac.new( + self.config.watermark_secret.encode(), + message.encode(), + hashlib.sha256, + ).hexdigest()[:32] + + def _get_hour_key(self) -> str: + """Get current hour key for grouping.""" + return str(int(time.time() / 3600)) + + # ------------------------------------------ + # Valkey-based tracking + # ------------------------------------------ + + async def _check_quota_valkey( + self, user: str, tier: QuotaTier + ) -> Tuple[bool, Dict[str, int]]: + """Check multi-window quotas using Valkey sorted sets.""" + r = await self._get_redis() + if not r: + return await self._fallback.check_quota(user, tier) + + try: + now = time.time() + prefix = self.config.key_prefix + windows = { + "minute": (60, tier.per_minute), + "hour": (3600, tier.per_hour), + "day": (86400, tier.per_day), + "month": (2592000, tier.per_month), + } + + pipe = r.pipeline() + for window_name, (window_size, _) in windows.items(): + key = f"{prefix}:quota:{user}:{window_name}" + pipe.zremrangebyscore(key, "-inf", now - window_size) + pipe.zcard(key) + + results = await pipe.execute() + + remaining = {} + allowed = True + for i, (window_name, (_, limit)) in enumerate(windows.items()): + count = results[i * 2 + 1] # zcard result + remaining[window_name] = max(0, limit - count) + if count >= limit: + allowed = False + + if allowed: + pipe = r.pipeline() + member = f"{now}:{random.randint(0, 999999)}" + for window_name, (window_size, _) in windows.items(): + key = f"{prefix}:quota:{user}:{window_name}" + pipe.zadd(key, {member: now}) + pipe.expire(key, window_size + 10) + await pipe.execute() + + return allowed, remaining + + except Exception: + self._valkey_available = False + return await self._fallback.check_quota(user, tier) + + async def _track_diversity_valkey( + self, user: str, category: str + ) -> int: + """Track category diversity using Valkey set.""" + r = await self._get_redis() + hour_key = self._get_hour_key() + if not r: + return await self._fallback.track_diversity(user, category, hour_key) + + try: + key = f"{self.config.key_prefix}:diversity:{user}:{hour_key}" + pipe = r.pipeline() + pipe.sadd(key, category) + pipe.scard(key) + pipe.expire(key, 3660) + results = await pipe.execute() + return results[1] + except Exception: + return await self._fallback.track_diversity(user, category, hour_key) + + async def _track_burst_valkey( + self, user: str, category: str + ) -> int: + """Track burst access using Valkey sorted set.""" + r = await self._get_redis() + if not r: + return await self._fallback.track_burst( + user, category, self.config.burst_window + ) + + try: + now = time.time() + key = f"{self.config.key_prefix}:burst:{user}:{category}" + pipe = r.pipeline() + pipe.zremrangebyscore(key, "-inf", now - self.config.burst_window) + pipe.zadd(key, {str(now): now}) + pipe.zcard(key) + pipe.expire(key, self.config.burst_window + 10) + results = await pipe.execute() + return results[2] + except Exception: + return await self._fallback.track_burst( + user, category, self.config.burst_window + ) + + async def _track_sequence_valkey( + self, user: str, category: str + ) -> List[str]: + """Track category sequence using Valkey list.""" + r = await self._get_redis() + if not r: + return await self._fallback.track_sequence(user, category) + + try: + key = f"{self.config.key_prefix}:seq:{user}" + pipe = r.pipeline() + pipe.rpush(key, category) + pipe.ltrim(key, -10, -1) # Keep last 10 + pipe.lrange(key, 0, -1) + pipe.expire(key, self.config.sequential_window + 10) + results = await pipe.execute() + return results[2] + except Exception: + return await self._fallback.track_sequence(user, category) + + async def _get_score_valkey(self, user: str) -> Tuple[float, float]: + """Get anomaly score from Valkey hash.""" + r = await self._get_redis() + if not r: + return await self._fallback.get_score(user) + + try: + key = f"{self.config.key_prefix}:score:{user}" + data = await r.hgetall(key) + if not data: + return 0.0, time.time() + return float(data.get("score", 0)), float(data.get("last_decay", time.time())) + except Exception: + return await self._fallback.get_score(user) + + async def _set_score_valkey(self, user: str, score: float, last_decay: float): + """Set anomaly score in Valkey hash.""" + r = await self._get_redis() + if not r: + await self._fallback.set_score(user, score, last_decay) + return + + try: + key = f"{self.config.key_prefix}:score:{user}" + pipe = r.pipeline() + pipe.hset(key, mapping={"score": str(score), "last_decay": str(last_decay)}) + pipe.expire(key, 86400) + await pipe.execute() + except Exception: + await self._fallback.set_score(user, score, last_decay) + + async def _track_tenant_valkey( + self, user: str, tenant_id: str + ) -> int: + """Track tenant access using Valkey set.""" + r = await self._get_redis() + hour_key = self._get_hour_key() + if not r: + return await self._fallback.track_tenant(user, tenant_id, hour_key) + + try: + key = f"{self.config.key_prefix}:tenants:{user}:{hour_key}" + pipe = r.pipeline() + pipe.sadd(key, tenant_id) + pipe.scard(key) + pipe.expire(key, 3660) + results = await pipe.execute() + return results[1] + except Exception: + return await self._fallback.track_tenant(user, tenant_id, hour_key) + + # ------------------------------------------ + # Anomaly Detection + # ------------------------------------------ + + def _check_sequential(self, sequence: List[str]) -> bool: + """Check if sequence of categories is suspiciously sorted.""" + if len(sequence) < self.config.sequential_min_entries: + return False + sorted_seq = sorted(sequence) + matches = sum(1 for a, b in zip(sequence, sorted_seq) if a == b) + ratio = matches / len(sequence) + return ratio >= self.config.sequential_sorted_ratio + + def _is_unusual_hour(self) -> bool: + """Check if current UTC hour is unusual.""" + import datetime + hour = datetime.datetime.utcnow().hour + return self.config.unusual_hours_start <= hour < self.config.unusual_hours_end + + def _apply_decay( + self, score: float, last_decay: float, now: float + ) -> Tuple[float, float]: + """Apply time-based score decay.""" + elapsed = now - last_decay + intervals = int(elapsed / self.config.score_decay_interval) + if intervals > 0: + for _ in range(intervals): + score *= self.config.score_decay_factor + last_decay = now + return max(0.0, score), last_decay + + def _get_throttle_level(self, score: float) -> int: + """Determine throttle level from anomaly score.""" + if score >= self.config.throttle_level_3_score: + return 3 + if score >= self.config.throttle_level_2_score: + return 2 + if score >= self.config.throttle_level_1_score: + return 1 + return 0 + + # ------------------------------------------ + # Main dispatch + # ------------------------------------------ + + async def dispatch(self, request: Request, call_next) -> Response: + path = request.url.path + + # Skip non-protected paths + if not self._is_protected_path(path): + return await call_next(request) + + # Extract user + user_id = self._get_user_id(request) + if not user_id: + # No user identified - let other middleware handle auth + return await call_next(request) + + # Get tier and category + tier_name = self._get_user_tier(request) + tier = self.config.tiers.get(tier_name, self.config.tiers[self.config.default_tier]) + category = _extract_category(path) + + # --- Multi-Window Quota Check --- + allowed, remaining = await self._check_quota_valkey(user_id, tier) + if not allowed: + logger.warning( + "SDK quota exceeded for user=%s tier=%s path=%s", + user_id[:16], tier_name, path, + ) + return JSONResponse( + status_code=429, + content={ + "error": "sdk_quota_exceeded", + "message": "SDK request quota exceeded. Please try again later.", + "tier": tier_name, + "limits": { + "per_minute": tier.per_minute, + "per_hour": tier.per_hour, + "per_day": tier.per_day, + "per_month": tier.per_month, + }, + }, + headers={ + "Retry-After": "60", + "X-SDK-Quota-Remaining-Minute": str(remaining.get("minute", 0)), + "X-SDK-Quota-Remaining-Hour": str(remaining.get("hour", 0)), + }, + ) + + # --- Load Anomaly Score with Decay --- + now = time.time() + score, last_decay = await self._get_score_valkey(user_id) + score, last_decay = self._apply_decay(score, last_decay, now) + triggered_rules: List[str] = [] + + # --- Diversity Tracking --- + diversity_count = await self._track_diversity_valkey(user_id, category) + if diversity_count > self.config.diversity_threshold: + score += self.config.score_diversity_increment + triggered_rules.append("high_diversity") + + # --- Burst Detection --- + burst_count = await self._track_burst_valkey(user_id, category) + if burst_count > self.config.burst_threshold: + score += self.config.score_burst_increment + triggered_rules.append("burst_detected") + + # --- Sequential Enumeration Detection --- + sequence = await self._track_sequence_valkey(user_id, category) + if self._check_sequential(sequence): + score += self.config.score_sequential_increment + triggered_rules.append("sequential_enumeration") + + # --- Unusual Hours --- + if self._is_unusual_hour(): + score += self.config.score_unusual_hours_increment + triggered_rules.append("unusual_hours") + + # --- Multi-Tenant Detection --- + tenant_id = self._get_tenant_id(request) + if tenant_id: + tenant_count = await self._track_tenant_valkey(user_id, tenant_id) + if tenant_count > self.config.multi_tenant_threshold: + score += self.config.score_multi_tenant_increment + triggered_rules.append("multi_tenant") + + # Persist updated score + await self._set_score_valkey(user_id, score, last_decay) + + # --- Determine Throttle Level --- + throttle_level = self._get_throttle_level(score) + + if triggered_rules: + logger.info( + "SDK anomaly: user=%s score=%.1f level=%d rules=%s", + user_id[:16], score, throttle_level, ",".join(triggered_rules), + ) + + # Level 3: Block + if throttle_level >= 3: + logger.warning( + "SDK protection blocking user=%s score=%.1f", + user_id[:16], score, + ) + return JSONResponse( + status_code=429, + content={ + "error": "sdk_protection_triggered", + "message": "Anomalous access pattern detected. Request blocked.", + }, + headers={ + "Retry-After": "300", + "X-SDK-Throttle-Level": str(throttle_level), + }, + ) + + # Level 2: Heavy delay + reduced detail + if throttle_level == 2: + delay = random.uniform(5.0, 10.0) + await asyncio.sleep(delay) + + # Level 1: Light delay + elif throttle_level == 1: + delay = random.uniform(1.0, 3.0) + await asyncio.sleep(delay) + + # --- Forward Request --- + response = await call_next(request) + + # --- Response Headers --- + response.headers["X-SDK-Quota-Remaining-Minute"] = str(remaining.get("minute", 0)) + response.headers["X-SDK-Quota-Remaining-Hour"] = str(remaining.get("hour", 0)) + response.headers["X-SDK-Throttle-Level"] = str(throttle_level) + + if throttle_level >= 2: + response.headers["X-SDK-Detail-Reduced"] = "true" + + # Watermark + watermark = self._generate_watermark(user_id, now) + response.headers["X-BP-Trace"] = watermark + + return response diff --git a/backend/middleware_admin_api.py b/backend/middleware_admin_api.py index 027bbe0..1c10a34 100644 --- a/backend/middleware_admin_api.py +++ b/backend/middleware_admin_api.py @@ -102,6 +102,50 @@ class MiddlewareStatsResponse(BaseModel): top_ips: List[Dict[str, Any]] +class SDKAnomalyScoreResponse(BaseModel): + """Response model for SDK anomaly score.""" + id: str + user_id: str + score: float + throttle_level: int + triggered_rules: List[str] + endpoint_diversity_count: int + request_count_1h: int + snapshot_at: datetime + + +class SDKProtectionStatsResponse(BaseModel): + """Response model for SDK protection statistics.""" + total_users_tracked: int + users_level_0: int + users_level_1: int + users_level_2: int + users_level_3: int + avg_score: float + max_score: float + + +class SDKProtectionTierResponse(BaseModel): + """Response model for SDK protection tier.""" + tier_name: str + quota_per_minute: int + quota_per_hour: int + quota_per_day: int + quota_per_month: int + diversity_threshold: int + burst_threshold: int + + +class SDKProtectionTierUpdateRequest(BaseModel): + """Request model for updating SDK protection tier.""" + quota_per_minute: Optional[int] = None + quota_per_hour: Optional[int] = None + quota_per_day: Optional[int] = None + quota_per_month: Optional[int] = None + diversity_threshold: Optional[int] = None + burst_threshold: Optional[int] = None + + # ============================================== # Middleware Configuration Endpoints # ============================================== @@ -422,7 +466,7 @@ async def get_middleware_stats( pool = await get_db_pool() stats = [] - middlewares = ["request_id", "security_headers", "cors", "rate_limiter", "pii_redactor", "input_gate"] + middlewares = ["request_id", "security_headers", "cors", "rate_limiter", "pii_redactor", "input_gate", "sdk_protection"] for mw in middlewares: # Get event counts @@ -533,3 +577,221 @@ async def get_middleware_stats_by_name( for r in top_ips ], ) + + +# ============================================== +# SDK Protection Endpoints +# ============================================== + + +@router.get("/sdk-protection/scores", response_model=List[SDKAnomalyScoreResponse]) +async def list_sdk_anomaly_scores( + min_score: float = Query(0, ge=0), + limit: int = Query(50, le=500), + session: Session = Depends(require_permission("settings:read")), +): + """ + List current SDK anomaly scores. + + Requires: settings:read permission + """ + pool = await get_db_pool() + + rows = await pool.fetch(""" + SELECT DISTINCT ON (user_id) + id, user_id, score, throttle_level, triggered_rules, + endpoint_diversity_count, request_count_1h, snapshot_at + FROM sdk_anomaly_scores + WHERE score >= $1 + ORDER BY user_id, snapshot_at DESC + LIMIT $2 + """, min_score, limit) + + return [ + SDKAnomalyScoreResponse( + id=str(row["id"]), + user_id=row["user_id"], + score=float(row["score"]), + throttle_level=row["throttle_level"], + triggered_rules=row["triggered_rules"] or [], + endpoint_diversity_count=row["endpoint_diversity_count"] or 0, + request_count_1h=row["request_count_1h"] or 0, + snapshot_at=row["snapshot_at"], + ) + for row in rows + ] + + +@router.get("/sdk-protection/stats", response_model=SDKProtectionStatsResponse) +async def get_sdk_protection_stats( + session: Session = Depends(require_permission("settings:read")), +): + """ + Get SDK protection statistics (users per throttle level). + + Requires: settings:read permission + """ + pool = await get_db_pool() + + row = await pool.fetchrow(""" + WITH latest_scores AS ( + SELECT DISTINCT ON (user_id) + user_id, score, throttle_level + FROM sdk_anomaly_scores + WHERE snapshot_at > NOW() - INTERVAL '24 hours' + ORDER BY user_id, snapshot_at DESC + ) + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE throttle_level = 0) as level_0, + COUNT(*) FILTER (WHERE throttle_level = 1) as level_1, + COUNT(*) FILTER (WHERE throttle_level = 2) as level_2, + COUNT(*) FILTER (WHERE throttle_level = 3) as level_3, + COALESCE(AVG(score), 0) as avg_score, + COALESCE(MAX(score), 0) as max_score + FROM latest_scores + """) + + return SDKProtectionStatsResponse( + total_users_tracked=row["total"] or 0, + users_level_0=row["level_0"] or 0, + users_level_1=row["level_1"] or 0, + users_level_2=row["level_2"] or 0, + users_level_3=row["level_3"] or 0, + avg_score=float(row["avg_score"]), + max_score=float(row["max_score"]), + ) + + +@router.post("/sdk-protection/reset-score/{user_id}") +async def reset_sdk_anomaly_score( + user_id: str, + session: Session = Depends(require_permission("settings:write")), +): + """ + Reset anomaly score for a specific user. + + Requires: settings:write permission + """ + pool = await get_db_pool() + + # Insert a zero-score snapshot + await pool.execute(""" + INSERT INTO sdk_anomaly_scores (user_id, score, throttle_level, triggered_rules, + endpoint_diversity_count, request_count_1h) + VALUES ($1, 0, 0, '[]'::jsonb, 0, 0) + """, user_id) + + # Clear Valkey score if available + try: + import redis.asyncio as r + valkey_url = os.getenv("VALKEY_URL", "redis://localhost:6379") + client = r.from_url(valkey_url, decode_responses=True, socket_timeout=1.0) + await client.delete(f"sdk_protect:score:{user_id}") + await client.aclose() + except Exception: + pass # Best-effort Valkey cleanup + + # Log the event + await pool.execute(""" + INSERT INTO middleware_events (middleware_name, event_type, user_id, details) + VALUES ('sdk_protection', 'score_reset', $1, $2) + """, session.user_id, {"target_user": user_id}) + + return {"message": f"Anomaly score reset for user {user_id}"} + + +@router.get("/sdk-protection/tiers", response_model=List[SDKProtectionTierResponse]) +async def list_sdk_protection_tiers( + session: Session = Depends(require_permission("settings:read")), +): + """ + List SDK protection tier configurations. + + Requires: settings:read permission + """ + pool = await get_db_pool() + + rows = await pool.fetch(""" + SELECT tier_name, quota_per_minute, quota_per_hour, quota_per_day, + quota_per_month, diversity_threshold, burst_threshold + FROM sdk_protection_tiers + ORDER BY quota_per_minute + """) + + return [ + SDKProtectionTierResponse( + tier_name=row["tier_name"], + quota_per_minute=row["quota_per_minute"], + quota_per_hour=row["quota_per_hour"], + quota_per_day=row["quota_per_day"], + quota_per_month=row["quota_per_month"], + diversity_threshold=row["diversity_threshold"], + burst_threshold=row["burst_threshold"], + ) + for row in rows + ] + + +@router.put("/sdk-protection/tiers/{name}", response_model=SDKProtectionTierResponse) +async def update_sdk_protection_tier( + name: str, + data: SDKProtectionTierUpdateRequest, + session: Session = Depends(require_permission("settings:write")), +): + """ + Update an SDK protection tier. + + Requires: settings:write permission + """ + pool = await get_db_pool() + + # Build update dynamically + updates = [] + params = [name] + param_idx = 2 + + for field_name in [ + "quota_per_minute", "quota_per_hour", "quota_per_day", + "quota_per_month", "diversity_threshold", "burst_threshold", + ]: + value = getattr(data, field_name) + if value is not None: + updates.append(f"{field_name} = ${param_idx}") + params.append(value) + param_idx += 1 + + if not updates: + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="No fields to update") + + updates.append("updated_at = NOW()") + query = f""" + UPDATE sdk_protection_tiers + SET {", ".join(updates)} + WHERE tier_name = $1 + RETURNING tier_name, quota_per_minute, quota_per_hour, quota_per_day, + quota_per_month, diversity_threshold, burst_threshold + """ + + row = await pool.fetchrow(query, *params) + + if not row: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail=f"Tier '{name}' not found") + + # Log the change + await pool.execute(""" + INSERT INTO middleware_events (middleware_name, event_type, user_id, details) + VALUES ('sdk_protection', 'tier_updated', $1, $2) + """, session.user_id, {"tier": name, "changes": data.dict(exclude_none=True)}) + + return SDKProtectionTierResponse( + tier_name=row["tier_name"], + quota_per_minute=row["quota_per_minute"], + quota_per_hour=row["quota_per_hour"], + quota_per_day=row["quota_per_day"], + quota_per_month=row["quota_per_month"], + diversity_threshold=row["diversity_threshold"], + burst_threshold=row["burst_threshold"], + ) diff --git a/backend/tests/test_middleware.py b/backend/tests/test_middleware.py index 9ca2d7d..4ba8ca0 100644 --- a/backend/tests/test_middleware.py +++ b/backend/tests/test_middleware.py @@ -575,3 +575,315 @@ class TestMiddlewareStackIntegration: ) assert response.status_code == 200 assert response.json()["received"] == {"key": "value"} + + +# ============================================== +# SDK Protection Middleware Tests +# ============================================== + + +class TestSDKProtectionMiddleware: + """Tests for SDKProtectionMiddleware.""" + + def _create_app(self, **config_overrides): + """Helper to create test app with SDK protection.""" + from middleware.sdk_protection import SDKProtectionMiddleware, SDKProtectionConfig + + config_kwargs = { + "fallback_enabled": True, + "watermark_secret": "test-secret", + } + config_kwargs.update(config_overrides) + config = SDKProtectionConfig(**config_kwargs) + + app = FastAPI() + app.add_middleware(SDKProtectionMiddleware, config=config) + + @app.get("/api/v1/tom/access-control") + async def tom_access_control(): + return {"data": "access-control"} + + @app.get("/api/v1/tom/encryption") + async def tom_encryption(): + return {"data": "encryption"} + + @app.get("/api/v1/dsfa/threshold") + async def dsfa_threshold(): + return {"data": "threshold"} + + @app.get("/api/v1/dsfa/necessity") + async def dsfa_necessity(): + return {"data": "necessity"} + + @app.get("/api/v1/vvt/processing") + async def vvt_processing(): + return {"data": "processing"} + + @app.get("/api/v1/vvt/purposes") + async def vvt_purposes(): + return {"data": "purposes"} + + @app.get("/api/v1/vvt/categories") + async def vvt_categories(): + return {"data": "categories"} + + @app.get("/api/v1/vvt/recipients") + async def vvt_recipients(): + return {"data": "recipients"} + + @app.get("/api/v1/controls/list") + async def controls_list(): + return {"data": "controls"} + + @app.get("/api/v1/assessment/run") + async def assessment_run(): + return {"data": "assessment"} + + @app.get("/health") + async def health(): + return {"status": "healthy"} + + @app.get("/api/public") + async def public(): + return {"data": "public"} + + return app + + def test_allows_normal_request(self): + """Should allow normal requests under all limits.""" + app = self._create_app() + client = TestClient(app) + + response = client.get( + "/api/v1/tom/access-control", + headers={"X-API-Key": "test-user-key-123"}, + ) + assert response.status_code == 200 + assert response.json() == {"data": "access-control"} + + def test_quota_headers_present(self): + """Should include quota headers in response.""" + app = self._create_app() + client = TestClient(app) + + response = client.get( + "/api/v1/tom/access-control", + headers={"X-API-Key": "test-user-key-456"}, + ) + assert response.status_code == 200 + assert "X-SDK-Quota-Remaining-Minute" in response.headers + assert "X-SDK-Quota-Remaining-Hour" in response.headers + assert "X-SDK-Throttle-Level" in response.headers + + def test_blocks_after_quota_exceeded(self): + """Should return 429 when minute quota is exceeded.""" + from middleware.sdk_protection import SDKProtectionConfig, QuotaTier + + tiers = { + "free": QuotaTier("free", 3, 500, 3000, 50000), # Very low minute limit + } + app = self._create_app(tiers=tiers) + client = TestClient(app) + + api_key = "quota-test-user" + headers = {"X-API-Key": api_key} + + # Make requests up to the limit + for i in range(3): + response = client.get("/api/v1/tom/access-control", headers=headers) + assert response.status_code == 200, f"Request {i+1} should succeed" + + # Next request should be blocked + response = client.get("/api/v1/tom/access-control", headers=headers) + assert response.status_code == 429 + assert response.json()["error"] == "sdk_quota_exceeded" + + def test_diversity_tracking_increments_score(self): + """Score should increase when accessing many different categories.""" + from middleware.sdk_protection import SDKProtectionConfig + + app = self._create_app(diversity_threshold=3) # Low threshold for test + client = TestClient(app) + + api_key = "diversity-test-user" + headers = {"X-API-Key": api_key} + + # Access many different categories + endpoints = [ + "/api/v1/tom/access-control", + "/api/v1/tom/encryption", + "/api/v1/dsfa/threshold", + "/api/v1/dsfa/necessity", + "/api/v1/vvt/processing", + "/api/v1/vvt/purposes", + ] + + for endpoint in endpoints: + response = client.get(endpoint, headers=headers) + assert response.status_code in (200, 429) + + # After exceeding diversity, throttle level should increase + response = client.get("/api/v1/vvt/categories", headers=headers) + if response.status_code == 200: + level = int(response.headers.get("X-SDK-Throttle-Level", "0")) + assert level >= 0 # Score increased but may not hit threshold yet + + def test_burst_detection(self): + """Score should increase for rapid same-category requests.""" + from middleware.sdk_protection import SDKProtectionConfig + + app = self._create_app(burst_threshold=3) # Low threshold for test + client = TestClient(app) + + api_key = "burst-test-user" + headers = {"X-API-Key": api_key} + + # Burst access to same endpoint + for _ in range(5): + response = client.get("/api/v1/tom/access-control", headers=headers) + if response.status_code == 429: + break + + # After burst, throttle level should have increased + response = client.get("/api/v1/tom/encryption", headers=headers) + if response.status_code == 200: + level = int(response.headers.get("X-SDK-Throttle-Level", "0")) + assert level >= 0 # Score increased + + def test_sequential_enumeration_detection(self): + """Score should increase for alphabetically sorted access patterns.""" + from middleware.sdk_protection import ( + SDKProtectionMiddleware, + SDKProtectionConfig, + InMemorySDKProtection, + ) + + config = SDKProtectionConfig( + sequential_min_entries=5, + sequential_sorted_ratio=0.6, + ) + mw = SDKProtectionMiddleware.__new__(SDKProtectionMiddleware) + mw.config = config + + # Sorted sequence should be detected + sorted_seq = ["a_cat", "b_cat", "c_cat", "d_cat", "e_cat", "f_cat"] + assert mw._check_sequential(sorted_seq) is True + + # Random sequence should not be detected + random_seq = ["d_cat", "a_cat", "f_cat", "b_cat", "e_cat", "c_cat"] + assert mw._check_sequential(random_seq) is False + + # Too short sequence should not be detected + short_seq = ["a_cat", "b_cat"] + assert mw._check_sequential(short_seq) is False + + def test_progressive_throttling_level_1(self): + """Throttle level 1 should be set at score >= 30.""" + from middleware.sdk_protection import SDKProtectionMiddleware, SDKProtectionConfig + + config = SDKProtectionConfig() + mw = SDKProtectionMiddleware.__new__(SDKProtectionMiddleware) + mw.config = config + + assert mw._get_throttle_level(0) == 0 + assert mw._get_throttle_level(29) == 0 + assert mw._get_throttle_level(30) == 1 + assert mw._get_throttle_level(50) == 1 + assert mw._get_throttle_level(59) == 1 + + def test_progressive_throttling_level_3_blocks(self): + """Throttle level 3 should be set at score >= 85.""" + from middleware.sdk_protection import SDKProtectionMiddleware, SDKProtectionConfig + + config = SDKProtectionConfig() + mw = SDKProtectionMiddleware.__new__(SDKProtectionMiddleware) + mw.config = config + + assert mw._get_throttle_level(60) == 2 + assert mw._get_throttle_level(84) == 2 + assert mw._get_throttle_level(85) == 3 + assert mw._get_throttle_level(100) == 3 + + def test_score_decay_over_time(self): + """Score should decay over time using decay factor.""" + from middleware.sdk_protection import SDKProtectionMiddleware, SDKProtectionConfig + + config = SDKProtectionConfig( + score_decay_factor=0.5, # Aggressive decay for test + score_decay_interval=60, # 1 minute intervals + ) + mw = SDKProtectionMiddleware.__new__(SDKProtectionMiddleware) + mw.config = config + + now = time.time() + # Score 100, last decay 2 intervals ago + score, last_decay = mw._apply_decay(100.0, now - 120, now) + # 2 intervals: 100 * 0.5 * 0.5 = 25 + assert score == pytest.approx(25.0) + + # No decay if within same interval + score2, _ = mw._apply_decay(100.0, now - 30, now) + assert score2 == pytest.approx(100.0) + + def test_skips_non_protected_paths(self): + """Should not apply protection to non-SDK paths.""" + app = self._create_app() + client = TestClient(app) + + # Health endpoint should not be protected + response = client.get("/health") + assert response.status_code == 200 + assert "X-SDK-Throttle-Level" not in response.headers + + # Non-SDK path should not be protected + response = client.get("/api/public") + assert response.status_code == 200 + assert "X-SDK-Throttle-Level" not in response.headers + + def test_watermark_header_present(self): + """Response should include X-BP-Trace watermark header.""" + app = self._create_app() + client = TestClient(app) + + response = client.get( + "/api/v1/tom/access-control", + headers={"X-API-Key": "watermark-test-user"}, + ) + assert response.status_code == 200 + assert "X-BP-Trace" in response.headers + assert len(response.headers["X-BP-Trace"]) == 32 + + def test_fallback_to_inmemory(self): + """Should work with in-memory fallback when Valkey is unavailable.""" + from middleware.sdk_protection import SDKProtectionConfig + + # Point to non-existent Valkey + app = self._create_app(valkey_url="redis://nonexistent:9999") + client = TestClient(app) + + response = client.get( + "/api/v1/tom/access-control", + headers={"X-API-Key": "fallback-test-user"}, + ) + assert response.status_code == 200 + assert response.json() == {"data": "access-control"} + + def test_no_user_passes_through(self): + """Requests without user identification should pass through.""" + app = self._create_app() + client = TestClient(app) + + # No API key and no session + response = client.get("/api/v1/tom/access-control") + assert response.status_code == 200 + + def test_category_extraction(self): + """Category extraction should use longest prefix match.""" + from middleware.sdk_protection import _extract_category + + assert _extract_category("/api/v1/tom/access-control") == "tom_access_control" + assert _extract_category("/api/v1/tom/encryption") == "tom_encryption" + assert _extract_category("/api/v1/dsfa/threshold") == "dsfa_threshold" + assert _extract_category("/api/v1/vvt/processing") == "vvt_processing" + assert _extract_category("/api/v1/controls/anything") == "controls_general" + assert _extract_category("/api/unknown/path") == "unknown" diff --git a/docs-src/architecture/sdk-protection.md b/docs-src/architecture/sdk-protection.md new file mode 100644 index 0000000..66b48de --- /dev/null +++ b/docs-src/architecture/sdk-protection.md @@ -0,0 +1,317 @@ +# SDK Protection Middleware + +## 1. Worum geht es? + +Die SDK Protection Middleware schuetzt die Compliance-SDK-Endpunkte vor einer bestimmten Art von Angriff: der **systematischen Enumeration**. Was bedeutet das? + +> *Ein Wettbewerber registriert sich als zahlender Kunde und laesst ein Skript langsam und verteilt alle TOM-Controls, alle Pruefaspekte und alle Assessment-Kriterien abfragen. Aus den Ergebnissen rekonstruiert er die gesamte Compliance-Framework-Logik.* + +Der klassische Rate Limiter (100 Requests/Minute) hilft hier nicht, weil ein cleverer Angreifer langsam vorgeht -- vielleicht nur 20 Anfragen pro Minute, dafuer systematisch und ueber Stunden. Die SDK Protection erkennt solche Muster und reagiert darauf. + +!!! info "Kern-Designprinzip" + **Normale Nutzer merken nichts.** Ein Lehrer, der im TOM-Modul arbeitet, greift typischerweise auf 3-5 Kategorien zu und wiederholt Anfragen an gleiche Endpunkte. Ein Angreifer durchlaeuft dagegen 40+ Kategorien in alphabetischer Reihenfolge. Genau diesen Unterschied erkennt die Middleware. + +--- + +## 2. Wie funktioniert der Schutz? + +Die Middleware nutzt ein **Anomaly-Score-System**. Jeder Benutzer hat einen Score, der bei 0 beginnt. Verschiedene verdaechtige Verhaltensweisen erhoehen den Score. Ueber die Zeit sinkt er wieder ab. Je hoeher der Score, desto staerker wird der Benutzer gebremst. + +Man kann es sich wie eine Ampel vorstellen: + +| Score | Ampel | Wirkung | Beispiel | +|-------|-------|---------|----------| +| 0-29 | Gruen | Keine Einschraenkung | Normaler Nutzer | +| 30-59 | Gelb | 1-3 Sekunden Verzoegerung | Leicht auffaelliges Muster | +| 60-84 | Orange | 5-10 Sekunden Verzoegerung, reduzierte Details | Deutlich verdaechtiges Verhalten | +| 85+ | Rot | Zugriff blockiert (HTTP 429) | Sehr wahrscheinlich automatisierter Angriff | + +### Score-Zerfall + +Der Score sinkt automatisch: Alle 5 Minuten wird er mit dem Faktor 0,95 multipliziert. Ein Score von 60 faellt also innerhalb einer Stunde auf etwa 30 -- wenn kein neues verdaechtiges Verhalten hinzukommt. + +--- + +## 3. Was wird erkannt? + +Die Middleware erkennt fuenf verschiedene Anomalie-Muster: + +### 3.1 Hohe Kategorie-Diversitaet + +**Was:** Ein Benutzer greift innerhalb einer Stunde auf mehr als 40 verschiedene SDK-Kategorien zu. + +**Warum verdaechtig:** Ein normaler Nutzer arbeitet in der Regel mit 3-10 Kategorien. Wer systematisch alle durchlaeuft, sammelt vermutlich Daten. + +**Score-Erhoehung:** +15 + +``` +Normal: tom/access-control → tom/access-control → tom/encryption → tom/encryption + (3 verschiedene Kategorien in einer Stunde) + +Verdaechtig: tom/access-control → tom/encryption → tom/pseudonymization → tom/integrity + → tom/availability → tom/resilience → dsfa/threshold → dsfa/necessity → ... + (40+ verschiedene Kategorien in einer Stunde) +``` + +### 3.2 Burst-Erkennung + +**Was:** Ein Benutzer sendet mehr als 15 Anfragen an die gleiche Kategorie innerhalb von 2 Minuten. + +**Warum verdaechtig:** Selbst ein eifriger Nutzer klickt nicht 15-mal pro Minute auf denselben Endpunkt. Das deutet auf automatisiertes Scraping hin. + +**Score-Erhoehung:** +20 + +### 3.3 Sequentielle Enumeration + +**Was:** Die letzten 10 aufgerufenen Kategorien sind zu mindestens 70% in alphabetischer oder numerischer Reihenfolge. + +**Warum verdaechtig:** Menschen springen zwischen Kategorien -- sie arbeiten thematisch, nicht alphabetisch. Ein Skript dagegen iteriert oft ueber eine sortierte Liste. + +**Score-Erhoehung:** +25 + +``` +Verdaechtig: assessment_general → compliance_general → controls_general + → dsfa_measures → dsfa_necessity → dsfa_residual → dsfa_risks + → dsfa_threshold → eh_general → namespace_general + (alphabetisch sortiert = Skript-Verhalten) +``` + +### 3.4 Ungewoehnliche Uhrzeiten + +**Was:** Anfragen zwischen 0:00 und 5:00 Uhr UTC. + +**Warum verdaechtig:** Lehrer arbeiten tagsüber. Wer um 3 Uhr morgens SDK-Endpunkte abfragt, ist wahrscheinlich ein automatisierter Prozess. + +**Score-Erhoehung:** +10 + +### 3.5 Multi-Tenant-Zugriff + +**Was:** Ein Benutzer greift innerhalb einer Stunde auf mehr als 3 verschiedene Mandanten (Tenants) zu. + +**Warum verdaechtig:** Ein normaler Nutzer gehoert zu einem Mandanten. Wer mehrere durchprobiert, koennte versuchen, mandantenuebergreifend Daten zu sammeln. + +**Score-Erhoehung:** +15 + +--- + +## 4. Quota-System (Mengenbegrenzung) + +Zusaetzlich zum Anomaly-Score gibt es klassische Mengenbegrenzungen in vier Zeitfenstern: + +| Tier | pro Minute | pro Stunde | pro Tag | pro Monat | +|------|-----------|-----------|---------|-----------| +| **Free** | 30 | 500 | 3.000 | 50.000 | +| **Standard** | 60 | 1.500 | 10.000 | 200.000 | +| **Enterprise** | 120 | 5.000 | 50.000 | 1.000.000 | + +Wenn ein Limit in irgendeinem Zeitfenster ueberschritten wird, erhaelt der Nutzer sofort HTTP 429 -- unabhaengig vom Anomaly-Score. + +--- + +## 5. Architektur + +### Datenfluss eines SDK-Requests + +``` +Request kommt an + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Ist der Pfad geschuetzt? │ +│ (/api/sdk/*, /api/v1/tom/*, /api/v1/dsfa/*, ...) │ +│ Nein → direkt weiterleiten │ +└──────────────┬──────────────────────────────────────────────┘ + │ Ja + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ User + Tier + Kategorie extrahieren │ +│ (aus Session, API-Key oder X-SDK-Tier Header) │ +└──────────────┬──────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Multi-Window Quota pruefen │ +│ (Minute / Stunde / Tag / Monat) │ +│ Ueberschritten → HTTP 429 zurueck │ +└──────────────┬──────────────────────────────────────────────┘ + │ OK + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Anomaly-Score laden (aus Valkey) │ +│ Zeitbasierten Zerfall anwenden (×0,95 alle 5 min) │ +└──────────────┬──────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Anomalie-Detektoren ausfuehren: │ +│ ├── Diversity-Tracking (+15 wenn >40 Kategorien/h) │ +│ ├── Burst-Detection (+20 wenn >15 gleiche/2min) │ +│ ├── Sequential-Enumeration (+25 wenn sortiert) │ +│ ├── Unusual-Hours (+10 wenn 0-5 Uhr UTC) │ +│ └── Multi-Tenant (+15 wenn >3 Tenants/h) │ +└──────────────┬──────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Throttle-Level bestimmen │ +│ Level 3 (Score ≥85) → HTTP 429 │ +│ Level 2 (Score ≥60) → 5-10s Delay + reduzierte Details │ +│ Level 1 (Score ≥30) → 1-3s Delay │ +│ Level 0 → keine Einschraenkung │ +└──────────────┬──────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Request weiterleiten │ +│ Response-Headers setzen: │ +│ ├── X-SDK-Quota-Remaining-Minute/Hour │ +│ ├── X-SDK-Throttle-Level │ +│ ├── X-SDK-Detail-Reduced (bei Level ≥2) │ +│ └── X-BP-Trace (HMAC-Watermark) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Valkey-Datenstrukturen + +Die Middleware speichert alle Tracking-Daten in Valkey (Redis-Fork). Wenn Valkey nicht erreichbar ist, wird automatisch auf eine In-Memory-Implementierung zurueckgefallen. + +| Zweck | Valkey-Typ | Key-Muster | TTL | +|-------|-----------|------------|-----| +| Quota pro Zeitfenster | Sorted Set | `sdk_protect:quota:{user}:{window}` | Fenster + 10s | +| Kategorie-Diversitaet | Set | `sdk_protect:diversity:{user}:{stunde}` | 3660s | +| Burst-Tracking | Sorted Set | `sdk_protect:burst:{user}:{kategorie}` | 130s | +| Sequenz-Tracking | List | `sdk_protect:seq:{user}` | 310s | +| Anomaly-Score | Hash | `sdk_protect:score:{user}` | 86400s | +| Tenant-Tracking | Set | `sdk_protect:tenants:{user}:{stunde}` | 3660s | + +### Watermarking + +Jede Antwort enthaelt einen `X-BP-Trace` Header mit einem HMAC-basierten Fingerabdruck. Damit kann nachtraeglich nachgewiesen werden, welcher Benutzer wann welche Daten abgerufen hat -- ohne dass der Benutzer den Trace veraendern kann. + +--- + +## 6. Geschuetzte Endpunkte + +Die Middleware schuetzt alle Pfade, die SDK- und Compliance-relevante Daten liefern: + +| Pfad-Prefix | Bereich | +|-------------|---------| +| `/api/sdk/*` | SDK-Hauptendpunkte | +| `/api/compliance/*` | Compliance-Bewertungen | +| `/api/v1/tom/*` | Technisch-organisatorische Massnahmen | +| `/api/v1/dsfa/*` | Datenschutz-Folgenabschaetzung | +| `/api/v1/vvt/*` | Verarbeitungsverzeichnis | +| `/api/v1/controls/*` | Controls und Massnahmen | +| `/api/v1/assessment/*` | Assessment-Bewertungen | +| `/api/v1/eh/*` | Erwartungshorizonte | +| `/api/v1/namespace/*` | Namespace-Verwaltung | + +Nicht geschuetzt sind `/health`, `/metrics` und `/api/health`. + +--- + +## 7. Admin-Verwaltung + +Ueber das Admin-Dashboard koennen Anomaly-Scores eingesehen und verwaltet werden: + +| Endpoint | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/admin/middleware/sdk-protection/scores` | GET | Aktuelle Anomaly-Scores aller Benutzer | +| `/api/admin/middleware/sdk-protection/stats` | GET | Statistik: Benutzer pro Throttle-Level | +| `/api/admin/middleware/sdk-protection/reset-score/{user_id}` | POST | Score eines Benutzers zuruecksetzen | +| `/api/admin/middleware/sdk-protection/tiers` | GET | Tier-Konfigurationen anzeigen | +| `/api/admin/middleware/sdk-protection/tiers/{name}` | PUT | Tier-Limits aendern | + +--- + +## 8. Dateien und Quellcode + +| Datei | Beschreibung | +|-------|--------------| +| `backend/middleware/sdk_protection.py` | Kern-Middleware (~460 Zeilen) | +| `backend/middleware/__init__.py` | Export der Middleware-Klassen | +| `backend/main.py` | Registrierung im FastAPI-Stack | +| `backend/middleware_admin_api.py` | Admin-API-Endpoints | +| `backend/migrations/add_sdk_protection_tables.sql` | Datenbank-Migration | +| `backend/tests/test_middleware.py` | 14 Tests fuer alle Erkennungsmechanismen | + +--- + +## 9. Datenbank-Tabellen + +### sdk_anomaly_scores + +Speichert Snapshots der Anomaly-Scores fuer Audit und Analyse. + +| Spalte | Typ | Beschreibung | +|--------|-----|--------------| +| `id` | UUID | Primaerschluessel | +| `user_id` | VARCHAR(255) | Benutzer-Identifikation | +| `score` | DECIMAL(5,2) | Aktueller Anomaly-Score | +| `throttle_level` | SMALLINT | Aktueller Throttle-Level (0-3) | +| `triggered_rules` | JSONB | Welche Regeln ausgeloest wurden | +| `endpoint_diversity_count` | INT | Anzahl verschiedener Kategorien | +| `request_count_1h` | INT | Anfragen in der letzten Stunde | +| `snapshot_at` | TIMESTAMPTZ | Zeitpunkt des Snapshots | + +### sdk_protection_tiers + +Konfigurierbare Quota-Tiers, editierbar ueber die Admin-API. + +| Spalte | Typ | Beschreibung | +|--------|-----|--------------| +| `tier_name` | VARCHAR(50) | Name des Tiers (free, standard, enterprise) | +| `quota_per_minute` | INT | Maximale Anfragen pro Minute | +| `quota_per_hour` | INT | Maximale Anfragen pro Stunde | +| `quota_per_day` | INT | Maximale Anfragen pro Tag | +| `quota_per_month` | INT | Maximale Anfragen pro Monat | +| `diversity_threshold` | INT | Max verschiedene Kategorien pro Stunde | +| `burst_threshold` | INT | Max gleiche Kategorie in 2 Minuten | + +--- + +## 10. Konfiguration + +Die Middleware wird in `main.py` registriert: + +```python +from middleware import SDKProtectionMiddleware + +app.add_middleware(SDKProtectionMiddleware) +``` + +Alle Parameter koennen ueber die `SDKProtectionConfig` Dataclass angepasst werden. Die wichtigsten Umgebungsvariablen: + +| Variable | Default | Beschreibung | +|----------|---------|--------------| +| `VALKEY_URL` | `redis://localhost:6379` | Verbindung zur Valkey-Instanz | +| `SDK_WATERMARK_SECRET` | (generiert) | HMAC-Secret fuer Watermarks | + +--- + +## 11. Tests + +Die Middleware wird durch 14 automatisierte Tests abgedeckt: + +```bash +# Alle SDK Protection Tests ausfuehren +docker compose run --rm --no-deps backend \ + python -m pytest tests/test_middleware.py -v -k sdk +``` + +| Test | Prueft | +|------|--------| +| `test_allows_normal_request` | Normaler Request wird durchgelassen | +| `test_blocks_after_quota_exceeded` | 429 bei Quota-Ueberschreitung | +| `test_diversity_tracking_increments_score` | Viele Kategorien erhoehen den Score | +| `test_burst_detection` | Schnelle gleiche Anfragen erhoehen den Score | +| `test_sequential_enumeration_detection` | Alphabetische Muster werden erkannt | +| `test_progressive_throttling_level_1` | Delay bei Score >= 30 | +| `test_progressive_throttling_level_3_blocks` | Block bei Score >= 85 | +| `test_score_decay_over_time` | Score sinkt ueber die Zeit | +| `test_skips_non_protected_paths` | Nicht-SDK-Pfade bleiben frei | +| `test_watermark_header_present` | X-BP-Trace Header vorhanden | +| `test_fallback_to_inmemory` | Funktioniert ohne Valkey | +| `test_no_user_passes_through` | Anonyme Requests passieren | +| `test_category_extraction` | Korrekte Kategorie-Zuordnung | +| `test_quota_headers_present` | Response-Headers vorhanden | diff --git a/mkdocs.yml b/mkdocs.yml index 48899c6..3b9df9a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,7 @@ nav: - Multi-Agent: architecture/multi-agent.md - Secrets Management: architecture/secrets-management.md - DevSecOps: architecture/devsecops.md + - SDK Protection: architecture/sdk-protection.md - Environments: architecture/environments.md - Zeugnis-System: architecture/zeugnis-system.md - Services: diff --git a/website/app/admin/middleware/page.tsx b/website/app/admin/middleware/page.tsx index 80362cd..129ca4a 100644 --- a/website/app/admin/middleware/page.tsx +++ b/website/app/admin/middleware/page.tsx @@ -85,6 +85,11 @@ const MIDDLEWARE_INFO: Record