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
+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: