From 94988deef267466da532aaf5812c80ec7d9e39ba Mon Sep 17 00:00:00 2001 From: Hugh Brown Date: Tue, 3 Mar 2026 13:42:32 -0700 Subject: [PATCH] security: add rate limiting to agent auth and webhook ingest Agent token auth performed O(n) PBKDF2 operations per request with no rate limiting, enabling CPU exhaustion attacks. Webhook ingest had no rate limits either. Add an in-memory token-bucket rate limiter: - Agent auth: 20 requests/minute per IP - Webhook ingest: 60 requests/minute per IP Includes unit tests for the rate limiter. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/board_webhooks.py | 4 +++ backend/app/core/agent_auth.py | 4 +++ backend/app/core/rate_limit.py | 42 +++++++++++++++++++++++++++++ backend/tests/test_rate_limit.py | 45 +++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 backend/app/core/rate_limit.py create mode 100644 backend/tests/test_rate_limit.py diff --git a/backend/app/api/board_webhooks.py b/backend/app/api/board_webhooks.py index f19b48b9..4da9e35f 100644 --- a/backend/app/api/board_webhooks.py +++ b/backend/app/api/board_webhooks.py @@ -13,6 +13,7 @@ from sqlmodel import col, select from app.api.deps import get_board_for_user_read, get_board_for_user_write, get_board_or_404 from app.core.config import settings +from app.core.rate_limit import webhook_ingest_limiter from app.core.logging import get_logger from app.core.time import utcnow from app.db import crud @@ -476,6 +477,9 @@ async def ingest_board_webhook( session: AsyncSession = SESSION_DEP, ) -> BoardWebhookIngestResponse: """Open inbound webhook endpoint that stores payloads and nudges the board lead.""" + client_ip = request.client.host if request.client else "unknown" + if not webhook_ingest_limiter.is_allowed(client_ip): + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS) webhook = await _require_board_webhook( session, board_id=board.id, diff --git a/backend/app/core/agent_auth.py b/backend/app/core/agent_auth.py index 04cf6437..502de6d4 100644 --- a/backend/app/core/agent_auth.py +++ b/backend/app/core/agent_auth.py @@ -24,6 +24,7 @@ from sqlmodel import col, select from app.core.agent_tokens import verify_agent_token from app.core.logging import get_logger +from app.core.rate_limit import agent_auth_limiter from app.core.time import utcnow from app.db.session import get_session from app.models.agents import Agent @@ -112,6 +113,9 @@ async def get_agent_auth_context( session: AsyncSession = SESSION_DEP, ) -> AgentAuthContext: """Require and validate agent auth token from request headers.""" + client_ip = request.client.host if request.client else "unknown" + if not agent_auth_limiter.is_allowed(client_ip): + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS) resolved = _resolve_agent_token( agent_token, authorization, diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 00000000..2487474b --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,42 @@ +"""Simple in-memory token-bucket rate limiter for abuse prevention. + +This provides per-IP rate limiting without external dependencies. +For multi-process or distributed deployments, a Redis-based limiter +should be used instead. +""" + +from __future__ import annotations + +import time +from collections import defaultdict +from threading import Lock + + +class InMemoryRateLimiter: + """Token-bucket rate limiter keyed by arbitrary string (typically client IP).""" + + def __init__(self, *, max_requests: int, window_seconds: float) -> None: + self._max_requests = max_requests + self._window_seconds = window_seconds + self._buckets: dict[str, list[float]] = defaultdict(list) + self._lock = Lock() + + def is_allowed(self, key: str) -> bool: + """Return True if the request should be allowed, False if rate-limited.""" + now = time.monotonic() + cutoff = now - self._window_seconds + with self._lock: + timestamps = self._buckets[key] + # Prune expired entries + self._buckets[key] = [ts for ts in timestamps if ts > cutoff] + if len(self._buckets[key]) >= self._max_requests: + return False + self._buckets[key].append(now) + return True + + +# Shared limiter instances for specific endpoints. +# Agent auth: 20 attempts per 60 seconds per IP. +agent_auth_limiter = InMemoryRateLimiter(max_requests=20, window_seconds=60.0) +# Webhook ingest: 60 requests per 60 seconds per IP. +webhook_ingest_limiter = InMemoryRateLimiter(max_requests=60, window_seconds=60.0) diff --git a/backend/tests/test_rate_limit.py b/backend/tests/test_rate_limit.py new file mode 100644 index 00000000..5d2f025a --- /dev/null +++ b/backend/tests/test_rate_limit.py @@ -0,0 +1,45 @@ +"""Tests for the in-memory rate limiter.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +from app.core.rate_limit import InMemoryRateLimiter + + +def test_allows_requests_within_limit() -> None: + limiter = InMemoryRateLimiter(max_requests=5, window_seconds=60.0) + for _ in range(5): + assert limiter.is_allowed("client-a") is True + + +def test_blocks_requests_over_limit() -> None: + limiter = InMemoryRateLimiter(max_requests=3, window_seconds=60.0) + for _ in range(3): + assert limiter.is_allowed("client-a") is True + assert limiter.is_allowed("client-a") is False + assert limiter.is_allowed("client-a") is False + + +def test_separate_keys_have_independent_limits() -> None: + limiter = InMemoryRateLimiter(max_requests=2, window_seconds=60.0) + assert limiter.is_allowed("client-a") is True + assert limiter.is_allowed("client-a") is True + assert limiter.is_allowed("client-a") is False + # Different key still allowed + assert limiter.is_allowed("client-b") is True + assert limiter.is_allowed("client-b") is True + assert limiter.is_allowed("client-b") is False + + +def test_window_expiry_resets_limit() -> None: + limiter = InMemoryRateLimiter(max_requests=2, window_seconds=1.0) + assert limiter.is_allowed("client-a") is True + assert limiter.is_allowed("client-a") is True + assert limiter.is_allowed("client-a") is False + + # Simulate time passing beyond the window + future = time.monotonic() + 2.0 + with patch("time.monotonic", return_value=future): + assert limiter.is_allowed("client-a") is True