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

+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