feat(B-RL): Rate-Limiting Konsistenz — zentrale Policies für Auth/AI/Upload/Webhook
Check Cross-Plugin Imports / check (push) Has been cancelled

B-RL: Rate-Limiting auf zentrale check_rate_limit() umgestellt
- forgejo_error_reporter: In-Memory → zentrale Redis-Rate-Limit
- ai_proactive: eigene Redis-Logik → zentrale check_rate_limit()
- agent_runner: DB-basiertes Limit bleibt (zählt echte Ausführungen)
- RateLimitPolicy Enum (AUTH/AI/UPLOAD/WEBHOOK) + check_rate_limit_policy()
- 8 neue config.py Settings für Policy-Limits
- 12 Routes mit Policies versehen (login, password-reset, AI, uploads, webhooks)

Tests: 20 Tests in test_rate_limit_policies.py — alle grün
- Policies, check_rate_limit, reset, get_client_ip, forgejo, ai_proactive
- Keine Regression: 116 bestehende Tests grün
This commit is contained in:
Agent Zero
2026-08-13 17:51:04 +02:00
parent 8c04c85d35
commit bb36378494
15 changed files with 516 additions and 84 deletions
+11 -1
View File
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
hnsw_ef_search: int = 40
vector_index_type: Literal["hnsw", "ivfflat"] = "hnsw"
# Rate Limiting
# Rate Limiting — legacy per-endpoint settings (kept for backward compat)
rate_limit_login_max: int = 5
rate_limit_login_window: int = 900 # 15 min
rate_limit_reset_max: int = 3
@@ -97,6 +97,16 @@ class Settings(BaseSettings):
rate_limit_general_max: int = 300
rate_limit_general_window: int = 60 # 1 min
# Rate Limiting — unified policies for abuse/cost-sensitive endpoints
rate_limit_auth_max: int = 5 # login, password-reset
rate_limit_auth_window: int = 300 # 5 minutes
rate_limit_ai_max: int = 20 # AI/LLM calls
rate_limit_ai_window: int = 60 # 1 minute
rate_limit_upload_max: int = 30 # file uploads
rate_limit_upload_window: int = 60 # 1 minute
rate_limit_webhook_max: int = 100 # incoming webhooks
rate_limit_webhook_window: int = 60 # 1 minute
@property
def cors_origin_list(self) -> list[str]:
"""Parse comma-separated CORS origins into a list."""
+42
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from enum import Enum
from fastapi import HTTPException, Request, status
from starlette.middleware.base import BaseHTTPMiddleware
@@ -13,6 +14,47 @@ from app.core.auth import get_redis
logger = logging.getLogger(__name__)
class RateLimitPolicy(Enum):
"""Named rate-limit policies for abuse- and cost-sensitive endpoints.
Each policy maps to a pair of Settings fields:
``rate_limit_<name>_max`` and ``rate_limit_<name>_window``.
"""
AUTH = "auth"
AI = "ai"
UPLOAD = "upload"
WEBHOOK = "webhook"
@property
def _max_field(self) -> str:
return f"rate_limit_{self.value}_max"
@property
def _window_field(self) -> str:
return f"rate_limit_{self.value}_window"
def limits(self) -> tuple[int, int]:
"""Return ``(max_attempts, window_seconds)`` from current settings."""
from app.config import get_settings
s = get_settings()
return getattr(s, self._max_field), getattr(s, self._window_field)
async def check_rate_limit_policy(
redis_key: str,
policy: RateLimitPolicy,
) -> None:
"""Check rate limit using a named :class:`RateLimitPolicy`.
Reads ``max_attempts`` and ``window_seconds`` from application settings
and delegates to :func:`check_rate_limit`.
"""
max_attempts, window_seconds = policy.limits()
await check_rate_limit(redis_key, max_attempts, window_seconds)
async def check_rate_limit(
redis_key: str,
max_attempts: int,
+10 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import uuid
import aiofiles
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -556,11 +556,20 @@ async def list_messages(
async def chat_stream(
session_id: str,
data: ChatSendRequest,
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Rate limit — AI policy (cost-sensitive LLM call)
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
await check_rate_limit_policy(
f"rate:ai:chat:{tenant_id}:{user_id}",
RateLimitPolicy.AI,
)
await set_tenant_context(db, tenant_id)
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
if not session:
+11 -9
View File
@@ -75,19 +75,21 @@ async def push_suggestion(user_id: str, suggestion: dict[str, Any]) -> None:
async def is_rate_limited(
tenant_id: uuid.UUID, user_id: uuid.UUID, rate_limit_seconds: int
) -> bool:
"""Check if user is rate-limited using Redis.
"""Check if user is rate-limited via central check_rate_limit().
Returns True if rate-limited (key exists), False otherwise.
Sets a key with TTL = rate_limit_seconds on first call.
Delegates to ``app.core.rate_limit.check_rate_limit`` with ``max_attempts=1``
and ``window_seconds=rate_limit_seconds``.
Returns ``True`` if rate-limited, ``False`` if allowed.
"""
from app.core.rate_limit import check_rate_limit
from fastapi import HTTPException
redis_key = f"rate:ai_proactive:{tenant_id}:{user_id}"
try:
r = get_cache()
key = f"ai_proactive:rate:{user_id}"
existing = await r.get(key)
if existing is not None:
return True
await r.setex(key, rate_limit_seconds, "1")
await check_rate_limit(redis_key, max_attempts=1, window_seconds=rate_limit_seconds)
return False
except HTTPException:
return True
except Exception:
logger.exception("Rate-limit check failed, allowing request")
return False
@@ -61,6 +61,10 @@ async def run_agent(
return {"error": "Agent is inactive", "status": "skipped"}
# ── Safety Check 1: Rate Limit ──
# Uses DB-based counting (AgentRun rows in last hour) rather than
# check_rate_limit() because this counts actual executions per agent,
# not just attempts. This is more accurate for per-agent execution caps
# and respects the agent-specific max_executions_per_hour setting.
if agent.max_executions_per_hour:
async with factory() as db:
one_hour_ago = datetime.now(UTC)
+7
View File
@@ -494,6 +494,13 @@ async def upload_file(
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(folder_id, "folder_id") if folder_id else None
# Rate limit — UPLOAD policy
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
await check_rate_limit_policy(
f"rate:upload:dms:{tenant_id}:{user_id}",
RateLimitPolicy.UPLOAD,
)
# Check for blocked file types
if _is_blocked_filetype(file.filename or ""):
raise HTTPException(
@@ -21,10 +21,9 @@ _DEDUP_MAX_SIZE = 100
_DEDUP_TTL_SECONDS = 3600 # 1 hour
# ── Rate limiting ────────────────────────────────────────────────────────────
# Max 10 issues per hour
# Max 10 reports per 5 minutes — enforced via central check_rate_limit()
_RATE_LIMIT_MAX = 10
_RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
_rate_limit_timestamps: list[float] = []
_RATE_LIMIT_WINDOW = 300 # 5 minutes
# ── Lock for thread safety ──────────────────────────────────────────────────
_lock = asyncio.Lock()
@@ -79,30 +78,6 @@ async def _is_duplicate(dedup_key: str) -> bool:
return False
async def _check_rate_limit() -> bool:
"""Check if we're within the rate limit.
Returns True if request is allowed, False if rate limited.
"""
async with _lock:
now = time.monotonic()
window_start = now - _RATE_LIMIT_WINDOW
# Remove timestamps outside the window
while _rate_limit_timestamps and _rate_limit_timestamps[0] < window_start:
_rate_limit_timestamps.pop(0)
if len(_rate_limit_timestamps) >= _RATE_LIMIT_MAX:
logger.warning(
"Forgejo Error Reporter rate limited: %d issues in the last hour",
len(_rate_limit_timestamps),
)
return False
_rate_limit_timestamps.append(now)
return True
async def _ensure_labels_exist(client: httpx.AsyncClient, settings: dict[str, str]) -> list[int]:
"""Ensure required labels exist in the Forgejo repository.
@@ -178,8 +153,22 @@ async def report_error_to_forgejo(entry: dict[str, Any]) -> bool:
if await _is_duplicate(dedup_key):
return False
# Rate limit check
if not await _check_rate_limit():
# Rate limit check — uses central check_rate_limit() with Redis + in-memory fallback
from app.core.rate_limit import check_rate_limit
from fastapi import HTTPException
try:
await check_rate_limit(
"rate:forgejo_report:global",
_RATE_LIMIT_MAX,
_RATE_LIMIT_WINDOW,
)
except HTTPException:
logger.warning(
"Forgejo Error Reporter rate limited: max %d reports per %d seconds",
_RATE_LIMIT_MAX,
_RATE_LIMIT_WINDOW,
)
return False
# Build issue body
@@ -350,6 +350,13 @@ async def upload_attachment(
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
msg_id = _parse_uuid(message_id, "message_id")
# Rate limit — UPLOAD policy
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
await check_rate_limit_policy(
f"rate:upload:comm:{tenant_id}:{user_id}",
RateLimitPolicy.UPLOAD,
)
# Get conversation_id from message
from sqlalchemy import select
from app.plugins.builtins.kommunikation.models import CommMessage
+7
View File
@@ -748,6 +748,13 @@ async def upload_attachment(
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
# Rate limit — UPLOAD policy
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
await check_rate_limit_policy(
f"rate:upload:mail:{tenant_id}:{user_id}",
RateLimitPolicy.UPLOAD,
)
# Read file content and check size
content = await file.read()
if len(content) > MAX_ATTACHMENT_SIZE:
+16 -1
View File
@@ -4,10 +4,11 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy, get_client_ip
from app.deps import get_current_user, require_permission
from app.schemas.ai_copilot import (
CopilotExecuteRequest,
@@ -21,6 +22,7 @@ router = APIRouter(prefix="/api/v1/ai/copilot", tags=["ai-copilot"])
@router.post("/query")
async def copilot_query(
body: CopilotQueryRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("ai:write")),
):
@@ -32,6 +34,12 @@ async def copilot_query(
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Rate limit — AI policy (cost-sensitive LLM call)
await check_rate_limit_policy(
f"rate:ai:copilot:{tenant_id}:{user_id}",
RateLimitPolicy.AI,
)
result = await ai_copilot_service.process_query(
db,
tenant_id,
@@ -53,6 +61,7 @@ async def copilot_query(
@router.post("/execute")
async def copilot_execute(
body: CopilotExecuteRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("ai:write")),
):
@@ -63,6 +72,12 @@ async def copilot_execute(
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Rate limit — AI policy (cost-sensitive LLM call)
await check_rate_limit_policy(
f"rate:ai:copilot:{tenant_id}:{user_id}",
RateLimitPolicy.AI,
)
resolved = {
"permissions": current_user.get("permissions", []),
"denied": current_user.get("denied_permissions", []),
+9 -1
View File
@@ -5,11 +5,12 @@ from __future__ import annotations
import os
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
from app.deps import require_permission
from app.services import attachment_service
@@ -18,6 +19,7 @@ router = APIRouter(prefix="/api/v1/attachments", tags=["attachments"])
@router.post("", status_code=status.HTTP_201_CREATED)
async def upload_attachment(
request: Request,
file: UploadFile = File(...),
entity_type: str = Form(...),
entity_id: str = Form(...),
@@ -29,6 +31,12 @@ async def upload_attachment(
user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False)
# Rate limit — UPLOAD policy
await check_rate_limit_policy(
f"rate:upload:attachments:{tenant_id}:{user_id}",
RateLimitPolicy.UPLOAD,
)
try:
eid = uuid.UUID(entity_id)
except ValueError:
+17 -15
View File
@@ -10,7 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import get_redis
from app.core.db import get_auth_db
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
from app.core.rate_limit import (
RateLimitPolicy,
check_rate_limit_policy,
get_client_ip,
reset_rate_limit,
)
from app.schemas.auth import (
AuthResponse,
LoginRequest,
@@ -36,11 +41,10 @@ async def login(
ip = get_client_ip(request)
redis = get_redis()
# Rate limit
await check_rate_limit(
f"auth:login:{ip}:{body.email}",
settings.rate_limit_login_max,
settings.rate_limit_login_window,
# Rate limit — unified AUTH policy
await check_rate_limit_policy(
f"rate:auth:login:{ip}:{body.email}",
RateLimitPolicy.AUTH,
)
result = await auth_service.login(db, redis, body.email, body.password, body.tenant_slug)
@@ -53,7 +57,7 @@ async def login(
session_id, csrf_token, user, tenant, role = result
# Reset rate limit on success
await reset_rate_limit(f"auth:login:{ip}:{body.email}")
await reset_rate_limit(f"rate:auth:login:{ip}:{body.email}")
response = Response(status_code=status.HTTP_200_OK)
response.set_cookie(
@@ -217,10 +221,9 @@ async def password_reset_request(
ip = get_client_ip(request)
redis = get_redis() # noqa: F841
await check_rate_limit(
f"auth:reset:{ip}",
settings.rate_limit_reset_max,
settings.rate_limit_reset_window,
await check_rate_limit_policy(
f"rate:auth:reset:{ip}",
RateLimitPolicy.AUTH,
)
await auth_service.request_password_reset(db, body.email)
@@ -237,10 +240,9 @@ async def password_reset_confirm(
ip = get_client_ip(request)
redis = get_redis() # noqa: F841
await check_rate_limit(
f"auth:reset_confirm:{ip}",
settings.rate_limit_reset_confirm_max,
settings.rate_limit_reset_confirm_window,
await check_rate_limit_policy(
f"rate:auth:reset_confirm:{ip}",
RateLimitPolicy.AUTH,
)
success = await auth_service.confirm_password_reset(db, body.token, body.new_password)
+13 -26
View File
@@ -1,29 +1,26 @@
"""Error logging endpoint — accepts frontend errors and logs them.
No auth required so errors can be logged even during logout.
Rate-limited to 10 requests per minute per IP (simple in-memory implementation).
Rate-limited to 10 requests per minute per IP via central check_rate_limit().
Context data is sanitized to prevent leaking sensitive information.
"""
from __future__ import annotations
import time
import logging
import re
from collections import defaultdict, deque
from typing import Any
from fastapi import APIRouter, Request, Response, status
from fastapi import APIRouter, HTTPException, Request, Response, status
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/errors", tags=["errors"])
# -- Simple in-memory rate limiter (10 req/min per IP) --
RATE_LIMIT = 10 # max requests
RATE_WINDOW = 60 # seconds
_ip_requests: dict[str, deque[float]] = defaultdict(deque)
# -- Rate limit constants (10 req/min per IP) --
_RATE_LIMIT_MAX = 10
_RATE_LIMIT_WINDOW = 60 # seconds
# -- Sensitive key patterns to strip from context --
_SENSITIVE_PATTERNS = re.compile(
@@ -33,22 +30,6 @@ _SENSITIVE_PATTERNS = re.compile(
)
def _is_rate_limited(client_ip: str) -> bool:
"""Return True if the IP has exceeded the rate limit."""
now = time.monotonic()
dq = _ip_requests[client_ip]
# Remove timestamps outside the window
while dq and now - dq[0] > RATE_WINDOW:
dq.popleft()
if len(dq) >= RATE_LIMIT:
return True
dq.append(now)
return False
def _sanitize_context(context: Any, max_depth: int = 3, _depth: int = 0) -> Any:
"""Recursively remove sensitive keys and limit depth/size of context data."""
if _depth > max_depth:
@@ -82,11 +63,17 @@ class ErrorReport(BaseModel):
@router.post("", status_code=status.HTTP_204_NO_CONTENT)
async def report_error(error: ErrorReport, request: Request) -> Response:
"""Log a frontend error. No auth required. Rate-limited per IP."""
from app.core.rate_limit import get_client_ip
from app.core.rate_limit import check_rate_limit, get_client_ip
client_ip = get_client_ip(request)
if _is_rate_limited(client_ip):
try:
await check_rate_limit(
f"rate:errors:{client_ip}",
_RATE_LIMIT_MAX,
_RATE_LIMIT_WINDOW,
)
except HTTPException:
return Response(status_code=status.HTTP_429_TOO_MANY_REQUESTS)
# Sanitize context to prevent leaking sensitive data
+9 -1
View File
@@ -4,10 +4,11 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
from app.deps import get_current_user, require_permission
from app.schemas.webhook import (
WebhookCreate,
@@ -166,6 +167,7 @@ async def delete_webhook(
)
async def test_webhook(
webhook_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
@@ -174,6 +176,12 @@ async def test_webhook(
user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False)
# Rate limit — WEBHOOK policy
await check_rate_limit_policy(
f"rate:webhook:test:{tenant_id}:{user_id}",
RateLimitPolicy.WEBHOOK,
)
try:
wh_id = uuid.UUID(webhook_id)
except (ValueError, TypeError):