fix: rate limit /token and /revoke, bucket IPv6 by /64 and warn about untrusted forwarded headers (SEC-08)

This commit is contained in:
Latte committed 2026-10-14 17:29:12 +02:00
1 parent 782d416e46
commit f57c237bed
7 files changed
+273 -16

No files matched your search

+8 -1
View File
@@ -25,7 +25,14 @@ from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH, GiteaUpstream
from aegis_gitea_mcp.transports import HttpAuthExtras
#: Unauthenticated OAuth endpoints that get the per-IP rate limit.
RATE_LIMITED_PATHS = ("/register", "/authorize", CONSENT_PATH, CALLBACK_PATH)
RATE_LIMITED_PATHS = (
"/register",
"/authorize",
"/token",
"/revoke",
CONSENT_PATH,
CALLBACK_PATH,
)
PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
+22 -2
View File
@@ -10,6 +10,7 @@ keyed by a hash of the Authorization header and is only a second line.
from __future__ import annotations
import hashlib
import ipaddress
import logging
import time
from collections import OrderedDict, deque
@@ -26,6 +27,24 @@ MAX_IP_KEYS = 100_000
MAX_TOKEN_KEYS = 10_000
def client_bucket(client_ip: str) -> str:
"""Rate-limit key of a client address: IPv6 clients share one bucket per /64.
A single IPv6 subscriber holds a whole /64, so keying by the full address would
give it an unlimited supply of buckets. IPv4-mapped IPv6 addresses count as
their IPv4 address; anything that is not an IP address is used as is.
"""
try:
address = ipaddress.ip_address(client_ip.split("%")[0])
except ValueError:
return client_ip
if isinstance(address, ipaddress.IPv6Address):
if address.ipv4_mapped is not None:
return str(address.ipv4_mapped)
return str(ipaddress.IPv6Network((address, 64), strict=False))
return str(address)
@dataclass(frozen=True)
class RateLimitDecision:
"""Result of request rate-limit checks."""
@@ -139,8 +158,9 @@ class RequestRateLimiter:
Returns:
Rate limit decision.
"""
if not self._ip_limiter.allow(client_ip):
if self._ip_limiter.first_excess(client_ip):
bucket = client_bucket(client_ip)
if not self._ip_limiter.allow(bucket):
if self._ip_limiter.first_excess(bucket):
self._audit_excess(
"rate_limit_ip_exceeded",
"Per-IP request rate limit exceeded",
+60 -3
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import errno
import ipaddress
import logging
import sys
import time
@@ -30,6 +31,7 @@ from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from starlette.types import ASGIApp, Receive, Scope, Send
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from aegis_gitea_mcp import __version__
from aegis_gitea_mcp.audit import get_audit_logger
@@ -189,6 +191,62 @@ class RateLimitMiddleware:
await response(scope, receive, send)
# Forwarded headers
class ForwardedHeadersMiddleware:
"""Applies ``X-Forwarded-For`` and ``-Proto`` from trusted peers and warns about the rest.
Replaces uvicorn's own proxy-header handling (the runner passes
``proxy_headers=False``) so the original peer address is still known here:
a request that carries ``X-Forwarded-For`` from a peer outside
``FORWARDED_ALLOW_IPS`` is not honoured (the peer's own address is used) and
logs one warning, because that usually means the reverse proxy is not listed
(for example a Docker network address) and every client then shares the
proxy's rate-limit bucket.
Args:
app: The ASGI application.
trusted: ``FORWARDED_ALLOW_IPS`` (``*`` or a comma list of IPs and networks).
"""
def __init__(self, app: ASGIApp, trusted: str) -> None:
self.trusted = trusted
self._all = trusted.strip() == "*"
self._networks = [
ipaddress.ip_network(item.strip(), strict=False)
for item in trusted.split(",")
if item.strip() and item.strip() != "*"
]
self._app = ProxyHeadersMiddleware(app, trusted_hosts=trusted) # type: ignore[arg-type]
self._warned = False
def _peer_trusted(self, host: str) -> bool:
if self._all:
return True
try:
address = ipaddress.ip_address(host.split("%")[0])
except ValueError:
return False
return any(
address in network for network in self._networks if network.version == address.version
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and not self._warned:
client = scope.get("client")
has_forwarded = any(name == b"x-forwarded-for" for name, _ in scope.get("headers", []))
if has_forwarded and client and not self._peer_trusted(client[0]):
self._warned = True
logger.warning(
"a request carried X-Forwarded-For from a peer that is not in "
"FORWARDED_ALLOW_IPS, so the header is ignored and the peer address is used "
"as the client address; if this peer is your reverse proxy, add its address "
"to FORWARDED_ALLOW_IPS (all clients otherwise share one rate-limit bucket)"
)
await self._app(scope, receive, send) # type: ignore[arg-type]
# HTTP app
@@ -501,12 +559,11 @@ def run_http(settings: Settings) -> int:
if not any(isinstance(item, AccessLogQueryFilter) for item in access_logger.filters):
access_logger.addFilter(AccessLogQueryFilter())
uvicorn.run(
app,
ForwardedHeadersMiddleware(app, settings.forwarded_allow_ips),
host=settings.mcp_host,
port=settings.mcp_port,
log_level=settings.log_level.lower(),
proxy_headers=True,
forwarded_allow_ips=settings.forwarded_allow_ips,
proxy_headers=False, # ForwardedHeadersMiddleware applies them (and warns)
server_header=False,
)
return 0