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