feat(B-RL): Rate-Limiting Konsistenz — zentrale Policies für Auth/AI/Upload/Webhook
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user