fix: fail closed on an unwritable audit log, bound the rate limiter and harden startup

The audit logger is opened at startup (exit 2 on failure) and obtained before
every call, which is refused with audit_unavailable otherwise. Audit records only
take validated path and sudo values and use the request id as correlation id. The
rate limiter evicts idle keys, caps tracked keys, audits the first excess per key
and window and uses the settings passed to the app. FORWARDED_ALLOW_IPS is now a
setting, and startup warns about a missing host allow-list and unavailable OAuth.
This commit is contained in:
Latte committed 2026-10-14 16:24:51 +02:00
1 parent a3222d5ebe
commit cf3d2e17d3
16 files changed
+530 -113

No files matched your search

+5
View File
@@ -21,6 +21,11 @@ MCP_BEARER_TOKEN=
PUBLIC_BASE_URL= PUBLIC_BASE_URL=
# Extra Host header values accepted behind a reverse proxy (comma separated). # Extra Host header values accepted behind a reverse proxy (comma separated).
ALLOWED_HOSTS= ALLOWED_HOSTS=
# Proxy addresses whose X-Forwarded-For is trusted (comma separated IPs or
# networks). The default only trusts a proxy on this host. Behind a Docker proxy
# on another address list that address; use * only if the port is reachable
# solely from the proxy.
FORWARDED_ALLOW_IPS=127.0.0.1
# --- Per-user OAuth (HTTP only) ----------------------------------------------- # --- Per-user OAuth (HTTP only) -----------------------------------------------
OAUTH_ENABLED=false OAUTH_ENABLED=false
+5 -2
View File
@@ -237,8 +237,11 @@ when the proxy forwards a different `Host`.
Bind rules: the server binds to `127.0.0.1` by default. A non-loopback `MCP_HOST` refuses to Bind rules: the server binds to `127.0.0.1` by default. A non-loopback `MCP_HOST` refuses to
start (exit code 2) unless `MCP_BEARER_TOKEN` or a complete OAuth setup is configured. Requests start (exit code 2) unless `MCP_BEARER_TOKEN` or a complete OAuth setup is configured. Requests
with an unexpected `Host` header are rejected (DNS-rebinding protection). The MCP endpoint is with an unexpected `Host` header are rejected (DNS-rebinding protection). The MCP endpoint is
rate limited (`RATE_LIMIT_PER_MINUTE`), keyed by a hash of the Authorization header, else the rate limited (`RATE_LIMIT_PER_MINUTE`) per client IP and per hash of the Authorization header.
client IP. Behind a proxy on another address set `FORWARDED_ALLOW_IPS` so the real client IP is used. A
non-loopback bind without `PUBLIC_BASE_URL` or `ALLOWED_HOSTS` logs a startup warning (remote
`Host` headers get 421). The server also exits with code 2 when the audit log cannot be opened
for append, and refuses tool calls it cannot audit.
More in [docs/deployment.md](docs/deployment.md) and [docs/hardening.md](docs/hardening.md). More in [docs/deployment.md](docs/deployment.md) and [docs/hardening.md](docs/hardening.md).
+7
View File
@@ -55,6 +55,13 @@ keep elsewhere.
The log is written to `AUDIT_LOG_PATH`, default `DATA_DIR/audit.log` (`/var/lib/aegis-mcp` in the The log is written to `AUDIT_LOG_PATH`, default `DATA_DIR/audit.log` (`/var/lib/aegis-mcp` in the
image). Keep it on durable storage, readable and writable only by the service account. image). Keep it on durable storage, readable and writable only by the service account.
The server opens the file for append at startup and exits with code 2 (message on stderr) when
that fails, so it never runs unaudited. Each call also obtains the audit logger before it runs;
if that fails the call is refused with `audit_unavailable` and never reaches Gitea. A write
failure after a call has executed is only logged to stderr: the Gitea side effect already
happened and the caller must still receive the result. The `correlation_id` of a tool entry is
the request id, which is also the entry's `request_id`.
## Rotation ## Rotation
The server appends to one file and does not rotate it, because rotating mid-stream would break The server appends to one file and does not rotate it, because rotating mid-stream would break
+4 -3
View File
@@ -30,7 +30,8 @@ when OAuth is on.
| `MCP_PATH` | `/mcp` | Starts with a single `/`, no query, spaces or trailing slash, not just `/`. | | `MCP_PATH` | `/mcp` | Starts with a single `/`, no query, spaces or trailing slash, not just `/`. |
| `MCP_BEARER_TOKEN` | unset | Static bearer token for HTTP (admin identity). At least 32 characters. Generate with `openssl rand -hex 32`. | | `MCP_BEARER_TOKEN` | unset | Static bearer token for HTTP (admin identity). At least 32 characters. Generate with `openssl rand -hex 32`. |
| `PUBLIC_BASE_URL` | unset | Public URL behind a reverse proxy. Same URL rules as `GITEA_URL`, and `https` unless the host is loopback. Required for OAuth. | | `PUBLIC_BASE_URL` | unset | Public URL behind a reverse proxy. Same URL rules as `GITEA_URL`, and `https` unless the host is loopback. Required for OAuth. |
| `ALLOWED_HOSTS` | empty | Extra `Host` header values accepted behind a proxy. The loopback names with the port and the host of `PUBLIC_BASE_URL` are always allowed. Other `Host` values are rejected (DNS-rebinding protection). | | `ALLOWED_HOSTS` | empty | Extra `Host` header values accepted behind a proxy. The loopback names with the port and the host of `PUBLIC_BASE_URL` are always allowed. Other `Host` values are rejected (DNS-rebinding protection). A non-loopback `MCP_HOST` with neither `PUBLIC_BASE_URL` nor `ALLOWED_HOSTS` logs a startup warning on stderr, because remote clients then get 421. |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Comma list of IP addresses or networks (or `*`) whose `X-Forwarded-For` and `X-Forwarded-Proto` headers are trusted. Set it to the address of the reverse proxy when it is not on the same host, for example a Docker network. Otherwise the per-IP rate limit sees the proxy address for every client. `*` trusts every peer and logs a warning: only use it when the port is reachable solely from the proxy, since clients could otherwise spoof their IP. |
## Per-user OAuth (HTTP only) ## Per-user OAuth (HTTP only)
@@ -50,7 +51,7 @@ when OAuth is on.
| Variable | Default | Rules | | Variable | Default | Rules |
|---|---|---| |---|---|---|
| `DATA_DIR` | `~/.local/state/aegis-gitea-mcp` (`/var/lib/aegis-mcp` in the image) | `~` is expanded. Holds the token store (`oauth-tokens.json`, mode 0600) and the audit log. | | `DATA_DIR` | `~/.local/state/aegis-gitea-mcp` (`/var/lib/aegis-mcp` in the image) | `~` is expanded. Holds the token store (`oauth-tokens.json`, mode 0600) and the audit log. |
| `AUDIT_LOG_PATH` | `DATA_DIR/audit.log` | Path of the hash-chained audit log. See [audit-logging.md](audit-logging.md). | | `AUDIT_LOG_PATH` | `DATA_DIR/audit.log` | Path of the hash-chained audit log. See [audit-logging.md](audit-logging.md). The server opens it for append at startup and exits with code 2 and a message on stderr when it cannot. |
## Tool exposure ## Tool exposure
@@ -74,7 +75,7 @@ listed nor callable.
| `SECRET_DETECTION_MODE` | `mask` | `off`, `mask` or `block`. See below. | | `SECRET_DETECTION_MODE` | `mask` | `off`, `mask` or `block`. See below. |
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` or `CRITICAL`. Logs go to stderr. | | `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` or `CRITICAL`. Logs go to stderr. |
| `EXPOSE_ERROR_DETAILS` | `false` | When true, the exception class name is added to internal and transport errors. Never a stack trace. Do not enable in production. | | `EXPOSE_ERROR_DETAILS` | `false` | When true, the exception class name is added to internal and transport errors. Never a stack trace. Do not enable in production. |
| `RATE_LIMIT_PER_MINUTE` | `120` | At least 1. Per client on the HTTP MCP endpoint, keyed by a hash of the Authorization header, else the client IP. Excess requests get 429. | | `RATE_LIMIT_PER_MINUTE` | `120` | At least 1. Per client on the HTTP MCP endpoint, keyed by a hash of the Authorization header, else the client IP. Excess requests get 429. Only the first rejected request per client and minute is written to the audit log. Tracking is in memory and bounded (idle clients are forgotten). |
Secret detection: `mask` replaces secret-like strings in results with a placeholder, `block` Secret detection: `mask` replaces secret-like strings in results with a placeholder, `block`
withholds a whole result that contains one (`secret_blocked`), `off` disables pattern matching. withholds a whole result that contains one (`secret_blocked`), `off` disables pattern matching.
+9 -1
View File
@@ -81,7 +81,15 @@ in mind for long tool calls.
The server rejects requests whose `Host` is not allowed (DNS-rebinding protection). Allowed hosts The server rejects requests whose `Host` is not allowed (DNS-rebinding protection). Allowed hosts
are the loopback names with the port, the host of `PUBLIC_BASE_URL`, and `ALLOWED_HOSTS`. If are the loopback names with the port, the host of `PUBLIC_BASE_URL`, and `ALLOWED_HOSTS`. If
you reach it under another name, add that name to `ALLOWED_HOSTS`. you reach it under another name, add that name to `ALLOWED_HOSTS`. With a non-loopback
`MCP_HOST` (the container) and neither `PUBLIC_BASE_URL` nor `ALLOWED_HOSTS`, every remote `Host`
is rejected with 421, and the server logs a warning at startup naming the two variables.
The server trusts `X-Forwarded-For` only from `FORWARDED_ALLOW_IPS` (default `127.0.0.1`). When
the proxy reaches the container from another address, for example a Docker network, add that
address or network (`FORWARDED_ALLOW_IPS=172.18.0.0/16`). Without it every request looks like it
comes from the proxy and shares one per-IP rate limit bucket. `*` trusts any peer, so use it only
when the port is reachable solely from the proxy.
## OAuth application in Gitea ## OAuth application in Gitea
+1
View File
@@ -40,6 +40,7 @@ authentication), but it does not decide what is safe. This checklist is how you
rejected. rejected.
- Restrict who can reach the endpoint at the proxy or firewall too (VPN, allow list), as a - Restrict who can reach the endpoint at the proxy or firewall too (VPN, allow list), as a
second layer in front of bearer authentication. second layer in front of bearer authentication.
- Set `FORWARDED_ALLOW_IPS` to the proxy address only (not `*`), so a client cannot spoof its IP.
- Leave `RATE_LIMIT_PER_MINUTE` on. Put request-size and connection limits at the proxy. - Leave `RATE_LIMIT_PER_MINUTE` on. Put request-size and connection limits at the proxy.
- Use https for `GITEA_URL`. Plain http sends the token unencrypted (setup warns about it). - Use https for `GITEA_URL`. Plain http sends the token unencrypted (setup warns about it).
+10 -3
View File
@@ -34,6 +34,7 @@ client fails to parse the stream. Logs go to stderr; look at the client's MCP se
| `redirect` | Gitea answered with a redirect, which is never followed. Use the final URL (for example https) as `GITEA_URL`. | | `redirect` | Gitea answered with a redirect, which is never followed. Use the final URL (for example https) as `GITEA_URL`. |
| `payload_too_large` | The response is above `MAX_BINARY_BYTES`. Raise it or fetch a smaller object. | | `payload_too_large` | The response is above `MAX_BINARY_BYTES`. Raise it or fetch a smaller object. |
| `secret_blocked` | `SECRET_DETECTION_MODE=block` withheld a result containing secret-like content. Use `mask` if that is acceptable. | | `secret_blocked` | `SECRET_DETECTION_MODE=block` withheld a result containing secret-like content. Use `mask` if that is acceptable. |
| `audit_unavailable` | The audit log could not be opened, so the call was refused before it reached Gitea. Fix the permissions of `AUDIT_LOG_PATH` (or `DATA_DIR`) and restart. |
| `rate_limited` (429) | Gitea or this server's `RATE_LIMIT_PER_MINUTE` limit. | | `rate_limited` (429) | Gitea or this server's `RATE_LIMIT_PER_MINUTE` limit. |
A result with a `truncated` object was cut to `MAX_TOOL_RESPONSE_ITEMS` or A result with a `truncated` object was cut to `MAX_TOOL_RESPONSE_ITEMS` or
@@ -48,9 +49,15 @@ A tool the client cannot see is probably filtered by `TOOLSETS`, `READ_ONLY` or
10 seconds). Fix connectivity from the server's host. 10 seconds). Fix connectivity from the server's host.
- 401 on `/mcp`: no or wrong bearer token, and no valid OAuth token. The response has a - 401 on `/mcp`: no or wrong bearer token, and no valid OAuth token. The response has a
`WWW-Authenticate` header that points clients to the OAuth metadata. `WWW-Authenticate` header that points clients to the OAuth metadata.
- Request rejected with an invalid Host: the name you used is not allowed. Add it to - Request rejected with an invalid Host (421): the name you used is not allowed. Add it to
`ALLOWED_HOSTS` or set `PUBLIC_BASE_URL`. `ALLOWED_HOSTS` or set `PUBLIC_BASE_URL`. With a non-loopback `MCP_HOST` and neither variable
- 429: the client exceeded `RATE_LIMIT_PER_MINUTE`. the server prints a warning at startup that says so.
- 429: the client exceeded `RATE_LIMIT_PER_MINUTE`. If every client seems to share one limit,
the proxy address is not in `FORWARDED_ALLOW_IPS`, so all requests look like they come from
the proxy.
- The server exits with code 2 and "cannot open the audit log": the audit file or its directory
is not writable. Fix the permissions, or point `AUDIT_LOG_PATH` or `DATA_DIR` at a writable
location (in Docker, check the volume ownership). The server never runs without an audit log.
- Responses arrive in one chunk or time out behind nginx: set `proxy_buffering off;`. - Responses arrive in one chunk or time out behind nginx: set `proxy_buffering off;`.
## OAuth sign-in ## OAuth sign-in
+23
View File
@@ -128,6 +128,7 @@ class Settings(BaseSettings):
mcp_bearer_token: SecretStr | None = Field(default=None) mcp_bearer_token: SecretStr | None = Field(default=None)
public_base_url: str | None = Field(default=None) public_base_url: str | None = Field(default=None)
allowed_hosts: Annotated[list[str], NoDecode] = Field(default_factory=list) allowed_hosts: Annotated[list[str], NoDecode] = Field(default_factory=list)
forwarded_allow_ips: str = Field(default="127.0.0.1")
# Per-user OAuth (HTTP only) # Per-user OAuth (HTTP only)
oauth_enabled: bool = Field(default=False) oauth_enabled: bool = Field(default=False)
@@ -199,6 +200,28 @@ class Settings(BaseSettings):
raise ValueError("PUBLIC_BASE_URL must use https unless the host is loopback") raise ValueError("PUBLIC_BASE_URL must use https unless the host is loopback")
return normalized return normalized
@field_validator("forwarded_allow_ips", mode="before")
@classmethod
def _validate_forwarded_allow_ips(cls, value: Any) -> Any:
"""Accept ``*`` or a comma list of IP addresses and networks (blank means the default)."""
if not isinstance(value, str):
return value
items = [item.strip() for item in value.split(",") if item.strip()]
if not items:
return "127.0.0.1"
if "*" in items and len(items) > 1:
raise ValueError("FORWARDED_ALLOW_IPS: '*' cannot be combined with other entries")
for item in items:
if item == "*":
continue
try:
ipaddress.ip_network(item, strict=False)
except ValueError:
raise ValueError(
"FORWARDED_ALLOW_IPS must be '*' or a comma list of IP addresses or networks"
) from None
return ",".join(items)
@field_validator("mcp_path") @field_validator("mcp_path")
@classmethod @classmethod
def _validate_mcp_path(cls, value: str) -> str: def _validate_mcp_path(cls, value: str) -> str:
+98 -43
View File
@@ -1,14 +1,29 @@
"""In-memory request rate limiting for MCP endpoints.""" """In-memory request rate limiting for the HTTP MCP endpoint.
The limiter runs before authentication, so every key it sees is attacker
controlled. Memory is therefore bounded: a key whose window has emptied is
dropped, and each limiter holds at most ``max_keys`` keys (least recently used
keys are evicted first). Per-IP limiting always applies; the per-token bucket is
keyed by a hash of the Authorization header and is only a second line.
"""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import logging
import time import time
from collections import defaultdict, deque from collections import OrderedDict, deque
from dataclasses import dataclass from dataclasses import dataclass
from aegis_gitea_mcp.audit import get_audit_logger from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings from aegis_gitea_mcp.config import Settings
logger = logging.getLogger(__name__)
#: Maximum number of distinct client IPs tracked at once.
MAX_IP_KEYS = 100_000
#: Maximum number of distinct Authorization hashes tracked at once.
MAX_TOKEN_KEYS = 10_000
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -20,25 +35,52 @@ class RateLimitDecision:
class SlidingWindowLimiter: class SlidingWindowLimiter:
"""Sliding-window limiter keyed by arbitrary identifiers.""" """Sliding-window limiter keyed by arbitrary identifiers, with bounded memory."""
def __init__(self, max_requests: int, window_seconds: int) -> None: def __init__(self, max_requests: int, window_seconds: int, max_keys: int = MAX_IP_KEYS) -> None:
"""Initialize a fixed-window limiter. """Initialize a sliding-window limiter.
Args: Args:
max_requests: Maximum allowed requests within window. max_requests: Maximum allowed requests within window.
window_seconds: Rolling time window length. window_seconds: Rolling time window length.
max_keys: Maximum number of tracked keys; the least recently used key is
evicted beyond it.
""" """
self.max_requests = max_requests self.max_requests = max_requests
self.window_seconds = window_seconds self.window_seconds = window_seconds
self._events: dict[str, deque[float]] = defaultdict(deque) self.max_keys = max_keys
self._events: OrderedDict[str, deque[float]] = OrderedDict()
self._reported: dict[str, float] = {}
self._next_sweep = 0.0
def __len__(self) -> int:
return len(self._events)
def _sweep(self, now: float) -> None:
"""Drop keys with no event inside the window (at most once per window)."""
if now < self._next_sweep:
return
self._next_sweep = now + self.window_seconds
boundary = now - self.window_seconds
for key in [k for k, events in self._events.items() if not events or events[-1] < boundary]:
del self._events[key]
for key in [k for k in self._reported if k not in self._events]:
del self._reported[key]
def allow(self, key: str) -> bool: def allow(self, key: str) -> bool:
"""Check and record request for the provided key.""" """Check and record request for the provided key."""
now = time.time() now = time.time()
boundary = now - self.window_seconds boundary = now - self.window_seconds
self._sweep(now)
events = self._events[key] events = self._events.get(key)
if events is None:
events = self._events[key] = deque()
while len(self._events) > self.max_keys:
evicted, _ = self._events.popitem(last=False)
self._reported.pop(evicted, None)
else:
self._events.move_to_end(key)
while events and events[0] < boundary: while events and events[0] < boundary:
events.popleft() events.popleft()
@@ -48,33 +90,62 @@ class SlidingWindowLimiter:
events.append(now) events.append(now)
return True return True
def first_excess(self, key: str) -> bool:
"""Return True once per window for a key that is over its limit.
Callers use it to audit only the first rejected request of a burst instead
of every one (which would let an attacker fill the audit log).
"""
now = time.time()
reported_at = self._reported.get(key)
if reported_at is not None and now - reported_at < self.window_seconds:
return False
self._reported[key] = now
return True
class RequestRateLimiter: class RequestRateLimiter:
"""Combined per-IP and per-token request limiter.""" """Combined per-IP and per-token request limiter.
def __init__(self) -> None: Args:
"""Initialize with current settings.""" settings: Application settings (``RATE_LIMIT_PER_MINUTE``).
settings = get_settings() """
self._audit = get_audit_logger()
self._ip_limiter = SlidingWindowLimiter(settings.rate_limit_per_minute, 60) def __init__(self, settings: Settings) -> None:
self._token_limiter = SlidingWindowLimiter(settings.rate_limit_per_minute, 60) limit = settings.rate_limit_per_minute
self._ip_limiter = SlidingWindowLimiter(limit, 60, MAX_IP_KEYS)
self._token_limiter = SlidingWindowLimiter(limit, 60, MAX_TOKEN_KEYS)
@staticmethod
def _audit_excess(event_type: str, description: str, severity: str, client_ip: str) -> None:
"""Record a limit hit; an unavailable audit log must not break request handling."""
try:
get_audit_logger().log_security_event(
event_type=event_type,
description=description,
severity=severity,
metadata={"client_ip": client_ip},
)
except Exception as exc:
logger.error("audit log write failed: %s", type(exc).__name__)
def check(self, client_ip: str, token: str | None) -> RateLimitDecision: def check(self, client_ip: str, token: str | None) -> RateLimitDecision:
"""Evaluate request against IP and token limits. """Evaluate request against IP and token limits.
Args: Args:
client_ip: Request source IP. client_ip: Request source IP.
token: Optional authenticated API token. token: Optional Authorization header value.
Returns: Returns:
Rate limit decision. Rate limit decision.
""" """
if not self._ip_limiter.allow(client_ip): if not self._ip_limiter.allow(client_ip):
self._audit.log_security_event( if self._ip_limiter.first_excess(client_ip):
event_type="rate_limit_ip_exceeded", self._audit_excess(
description="Per-IP request rate limit exceeded", "rate_limit_ip_exceeded",
severity="medium", "Per-IP request rate limit exceeded",
metadata={"client_ip": client_ip}, "medium",
client_ip,
) )
return RateLimitDecision(False, "Per-IP rate limit exceeded") return RateLimitDecision(False, "Per-IP rate limit exceeded")
@@ -82,29 +153,13 @@ class RequestRateLimiter:
# Hash token before using it as a key to avoid storing secrets in memory maps. # Hash token before using it as a key to avoid storing secrets in memory maps.
token_key = hashlib.sha256(token.encode("utf-8")).hexdigest() token_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
if not self._token_limiter.allow(token_key): if not self._token_limiter.allow(token_key):
self._audit.log_security_event( if self._token_limiter.first_excess(token_key):
event_type="rate_limit_token_exceeded", self._audit_excess(
description="Per-token request rate limit exceeded", "rate_limit_token_exceeded",
severity="high", "Per-token request rate limit exceeded",
metadata={"client_ip": client_ip}, "high",
client_ip,
) )
return RateLimitDecision(False, "Per-token rate limit exceeded") return RateLimitDecision(False, "Per-token rate limit exceeded")
return RateLimitDecision(True, "within limits") return RateLimitDecision(True, "within limits")
_rate_limiter: RequestRateLimiter | None = None
def get_rate_limiter() -> RequestRateLimiter:
"""Get global request limiter."""
global _rate_limiter
if _rate_limiter is None:
_rate_limiter = RequestRateLimiter()
return _rate_limiter
def reset_rate_limiter() -> None:
"""Reset global limiter (primarily for tests)."""
global _rate_limiter
_rate_limiter = None
+35 -13
View File
@@ -347,6 +347,7 @@ class _Record:
"""Everything one audit entry needs, filled in as the call progresses.""" """Everything one audit entry needs, filled in as the call progresses."""
tool: str tool: str
request_id: str = ""
domain: str | None = None domain: str | None = None
method: str | None = None method: str | None = None
path: str | None = None path: str | None = None
@@ -405,10 +406,24 @@ class _Dispatcher:
) -> types.CallToolResult: ) -> types.CallToolResult:
"""Handle ``tools/call``; every call produces exactly one audit entry.""" """Handle ``tools/call``; every call produces exactly one audit entry."""
started = time.perf_counter() started = time.perf_counter()
set_request_id(uuid.uuid4().hex) request_id = uuid.uuid4().hex
set_request_id(request_id)
arguments = params.arguments or {} arguments = params.arguments or {}
record = _Record(tool=params.name[:_MAX_UNKNOWN_NAME_CHARS]) record = _Record(tool=params.name[:_MAX_UNKNOWN_NAME_CHARS], request_id=request_id)
record.argument_names = sorted(_clip(key) for key in arguments)[:_MAX_AUDIT_ARGUMENT_NAMES] record.argument_names = sorted(_clip(key) for key in arguments)[:_MAX_AUDIT_ARGUMENT_NAMES]
# Fail closed: the audit logger is obtained BEFORE anything runs, so a call is
# never executed (and its side effects never happen) without a way to record it.
try:
audit = self._audit_override or get_audit_logger()
except Exception as exc:
logger.error("audit log unavailable, refusing the call: %s", type(exc).__name__)
return _result(
_error_payload(
"audit_unavailable",
"The audit log is not writable, so the call was not executed",
),
is_error=True,
)
try: try:
entry = self._registry.get(params.name) entry = self._registry.get(params.name)
if entry is None: if entry is None:
@@ -426,7 +441,7 @@ class _Dispatcher:
message += f" ({type(exc).__name__})" message += f" ({type(exc).__name__})"
return _result(_error_payload("internal_error", message), is_error=True) return _result(_error_payload("internal_error", message), is_error=True)
finally: finally:
self._audit(record, (time.perf_counter() - started) * 1000) self._audit(audit, record, (time.perf_counter() - started) * 1000)
async def _dispatch( async def _dispatch(
self, entry: _Entry, arguments: dict[str, Any], record: _Record self, entry: _Entry, arguments: dict[str, Any], record: _Record
@@ -434,20 +449,19 @@ class _Dispatcher:
operation = entry.operation operation = entry.operation
if operation is not None: if operation is not None:
record.method, record.path = operation.method, operation.path record.method, record.path = operation.method, operation.path
else:
method, path = arguments.get("method"), arguments.get("path")
if isinstance(method, str):
record.method = method.upper()[:10]
if isinstance(path, str):
record.path = path[:_MAX_AUDIT_PATH_CHARS]
sudo = arguments.get("sudo")
record.sudo = sudo[:NAME_MAX_LENGTH] if isinstance(sudo, str) else None
invalid = validate_arguments(self._registry.validator(entry.tool.name), arguments) invalid = validate_arguments(self._registry.validator(entry.tool.name), arguments)
if invalid is None and operation is not None: if invalid is None and operation is not None:
invalid = _upload_cap_error(operation, arguments, self._settings.max_binary_bytes) invalid = _upload_cap_error(operation, arguments, self._settings.max_binary_bytes)
if invalid is not None: if invalid is not None:
return self._failed(record, "invalid_argument", {"error": invalid}) return self._failed(record, "invalid_argument", {"error": invalid})
# Only validated values reach the audit record: a rejected call may carry
# anything (including a secret) in its raw arguments.
sudo = arguments.get("sudo")
record.sudo = sudo[:NAME_MAX_LENGTH] if isinstance(sudo, str) else None
if operation is None:
record.method = arguments["method"]
record.path = arguments["path"][:_MAX_AUDIT_PATH_CHARS]
if operation is None and self._settings.read_only and arguments["method"] != "GET": if operation is None and self._settings.read_only and arguments["method"] != "GET":
message = "READ_ONLY mode is on: gitea_api_request only accepts GET" message = "READ_ONLY mode is on: gitea_api_request only accepts GET"
return self._failed(record, "read_only", _error_payload("read_only", message)) return self._failed(record, "read_only", _error_payload("read_only", message))
@@ -492,15 +506,23 @@ class _Dispatcher:
record.outcome = "success" record.outcome = "success"
return _result(outcome.payload, is_error=outcome.is_error, text=outcome.to_text()) return _result(outcome.payload, is_error=outcome.is_error, text=outcome.to_text())
def _audit(self, record: _Record, duration_ms: float) -> None: @staticmethod
def _audit(audit: AuditLogger, record: _Record, duration_ms: float) -> None:
"""Write the audit entry of a finished call.
A write failure here is logged and swallowed on purpose: the tool already
ran, its side effect (possibly a Gitea mutation) cannot be undone, and the
caller still has to learn the result. The fail-closed check happens before
execution, in :meth:`call_tool`.
"""
params: dict[str, Any] = {"argument_names": record.argument_names} params: dict[str, Any] = {"argument_names": record.argument_names}
if record.http_status is not None: if record.http_status is not None:
params["http_status"] = record.http_status params["http_status"] = record.http_status
try: try:
audit = self._audit_override or get_audit_logger()
audit.log_tool_invocation( audit.log_tool_invocation(
record.tool, record.tool,
params=params, params=params,
correlation_id=record.request_id,
result_status="error" if record.is_error else "success", result_status="error" if record.is_error else "success",
domain=record.domain, domain=record.domain,
method=record.method, method=record.method,
+66 -8
View File
@@ -31,9 +31,10 @@ from starlette.routing import Route
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
from aegis_gitea_mcp import __version__ from aegis_gitea_mcp import __version__
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.auth import CompositeVerifier, IdentityResolver, StaticBearerVerifier from aegis_gitea_mcp.auth import CompositeVerifier, IdentityResolver, StaticBearerVerifier
from aegis_gitea_mcp.config import Settings from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.rate_limit import get_rate_limiter from aegis_gitea_mcp.rate_limit import RequestRateLimiter
from aegis_gitea_mcp.server import ( from aegis_gitea_mcp.server import (
SERVER_NAME, SERVER_NAME,
AegisServer, AegisServer,
@@ -134,9 +135,10 @@ class GiteaProbe:
class RateLimitMiddleware: class RateLimitMiddleware:
"""ASGI middleware applying the per-IP and per-token limits to the MCP path.""" """ASGI middleware applying the per-IP and per-token limits to the MCP path."""
def __init__(self, app: ASGIApp, mcp_path: str) -> None: def __init__(self, app: ASGIApp, mcp_path: str, limiter: RequestRateLimiter) -> None:
self.app = app self.app = app
self._mcp_path = mcp_path self._mcp_path = mcp_path
self._limiter = limiter
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope["path"].rstrip("/") != self._mcp_path: if scope["type"] != "http" or scope["path"].rstrip("/") != self._mcp_path:
@@ -149,7 +151,7 @@ class RateLimitMiddleware:
if name == b"authorization": if name == b"authorization":
authorization = value.decode("latin-1") authorization = value.decode("latin-1")
break break
decision = get_rate_limiter().check(client_ip, authorization) decision = self._limiter.check(client_ip, authorization)
if decision.allowed: if decision.allowed:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
@@ -307,7 +309,9 @@ def build_http_app(
await gitea_probe.aclose() await gitea_probe.aclose()
app.router.lifespan_context = lifespan app.router.lifespan_context = lifespan
app.add_middleware(RateLimitMiddleware, mcp_path=settings.mcp_path) app.add_middleware(
RateLimitMiddleware, mcp_path=settings.mcp_path, limiter=RequestRateLimiter(settings)
)
return app return app
@@ -333,10 +337,56 @@ def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> li
return errors return errors
def http_startup_warnings(settings: Settings, extras: HttpAuthExtras | None) -> list[str]:
"""List non-fatal startup problems of the HTTP server (printed to stderr)."""
warnings: list[str] = []
if (
not settings.is_loopback_bind()
and not settings.public_base_url
and not settings.allowed_hosts
):
warnings.append(
f"MCP_HOST={settings.mcp_host} is not loopback and neither PUBLIC_BASE_URL nor "
"ALLOWED_HOSTS is set: requests whose Host header is not a loopback name or the "
"bind address are rejected with 421. Set PUBLIC_BASE_URL (the public https URL) "
"or ALLOWED_HOSTS (comma separated host names clients use)"
)
if settings.oauth_enabled and extras is None and settings.mcp_bearer_token is not None:
warnings.append(
"OAUTH_ENABLED=true but per-user OAuth is not available in this build: only "
"MCP_BEARER_TOKEN is accepted and every call runs as the GITEA_TOKEN owner"
)
if settings.forwarded_allow_ips == "*":
warnings.append(
"FORWARDED_ALLOW_IPS=*: X-Forwarded-For is trusted from any peer, so clients can "
"spoof their IP and dodge the per-IP rate limit. List the proxy addresses instead"
)
return warnings
def _audit_startup_error(settings: Settings) -> str | None:
"""Open the audit log now; return an error message when it is not writable.
A server that cannot record its calls must not start: the dispatcher refuses
calls without an audit log, so every call would fail anyway.
"""
try:
get_audit_logger()
except OSError as exc:
reason = exc.strerror or type(exc).__name__
return (
f"cannot open the audit log {settings.resolved_audit_log_path} for append "
f"({reason}). Fix the permissions or point AUDIT_LOG_PATH (or DATA_DIR) at a "
"writable location"
)
return None
def run_http(settings: Settings) -> int: def run_http(settings: Settings) -> int:
"""Run the streamable HTTP server; return a process exit code. """Run the streamable HTTP server; return a process exit code.
Returns 2 without starting when a startup check fails. Returns 2 without starting when a startup check fails (including an audit
log that cannot be opened for append).
""" """
extras = load_oauth_components(settings) if settings.oauth_enabled else None extras = load_oauth_components(settings) if settings.oauth_enabled else None
errors = http_startup_errors(settings, extras) errors = http_startup_errors(settings, extras)
@@ -344,6 +394,11 @@ def run_http(settings: Settings) -> int:
for message in errors: for message in errors:
_fail(message) _fail(message)
return EXIT_USAGE return EXIT_USAGE
audit_error = _audit_startup_error(settings)
if audit_error is not None:
return _fail(audit_error)
for message in http_startup_warnings(settings, extras):
print(f"aegis-gitea-mcp: warning: {message}", file=sys.stderr)
try: try:
server = build_server(settings, resolver=extras.resolver if extras else None) server = build_server(settings, resolver=extras.resolver if extras else None)
except ServerConfigError as exc: except ServerConfigError as exc:
@@ -363,7 +418,7 @@ def run_http(settings: Settings) -> int:
port=settings.mcp_port, port=settings.mcp_port,
log_level=settings.log_level.lower(), log_level=settings.log_level.lower(),
proxy_headers=True, proxy_headers=True,
forwarded_allow_ips=getattr(settings, "forwarded_allow_ips", None) or "127.0.0.1", forwarded_allow_ips=settings.forwarded_allow_ips,
server_header=False, server_header=False,
) )
return 0 return 0
@@ -377,11 +432,14 @@ async def _serve_stdio(server: AegisServer) -> None:
def run_stdio(settings: Settings) -> int: def run_stdio(settings: Settings) -> int:
"""Run the local stdio server (single identity: the owner of ``GITEA_TOKEN``). """Run the local stdio server (single identity: the owner of ``GITEA_TOKEN``).
Returns 2 without starting when ``GITEA_TOKEN`` is missing or the tool Returns 2 without starting when ``GITEA_TOKEN`` is missing, the audit log is
filters are invalid. All logging goes to stderr; stdout carries JSON-RPC only. not writable or the tool filters are invalid. All logging goes to stderr; stdout carries JSON-RPC only.
""" """
if settings.gitea_token is None: if settings.gitea_token is None:
return _fail("stdio needs GITEA_TOKEN; create a Gitea access token and set it in .env") return _fail("stdio needs GITEA_TOKEN; create a Gitea access token and set it in .env")
audit_error = _audit_startup_error(settings)
if audit_error is not None:
return _fail(audit_error)
try: try:
server = build_server(settings) server = build_server(settings)
except ServerConfigError as exc: except ServerConfigError as exc:
-2
View File
@@ -14,7 +14,6 @@ import pytest
from aegis_gitea_mcp.audit import reset_audit_logger from aegis_gitea_mcp.audit import reset_audit_logger
from aegis_gitea_mcp.config import Settings, reset_settings from aegis_gitea_mcp.config import Settings, reset_settings
from aegis_gitea_mcp.rate_limit import reset_rate_limiter
from aegis_gitea_mcp.request_context import clear_gitea_auth_context from aegis_gitea_mcp.request_context import clear_gitea_auth_context
@@ -22,7 +21,6 @@ def _reset_singletons() -> None:
"""Reset every module-level singleton.""" """Reset every module-level singleton."""
reset_settings() reset_settings()
reset_audit_logger() reset_audit_logger()
reset_rate_limiter()
clear_gitea_auth_context() clear_gitea_auth_context()
+86 -21
View File
@@ -2,21 +2,30 @@
from __future__ import annotations from __future__ import annotations
import json
import pytest import pytest
from aegis_gitea_mcp.audit import validate_audit_log_integrity from aegis_gitea_mcp.audit import get_audit_logger, validate_audit_log_integrity
from aegis_gitea_mcp.config import get_settings from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.rate_limit import ( from aegis_gitea_mcp.rate_limit import RequestRateLimiter, SlidingWindowLimiter
RequestRateLimiter, from tests.test_server_helpers import server_settings
SlidingWindowLimiter,
get_rate_limiter,
reset_rate_limiter,
)
def test_sliding_window_blocks_after_limit(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture
def clock(monkeypatch: pytest.MonkeyPatch) -> list[float]:
now = [1000.0] now = [1000.0]
monkeypatch.setattr("aegis_gitea_mcp.rate_limit.time.time", lambda: now[0]) monkeypatch.setattr("aegis_gitea_mcp.rate_limit.time.time", lambda: now[0])
return now
def security_events() -> list[dict[str, object]]:
path = get_audit_logger().log_path
entries = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
return [e["payload"] for e in entries if e["event_type"] == "security_event"]
def test_sliding_window_blocks_after_limit(clock: list[float]) -> None:
limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60) limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60)
assert limiter.allow("k") assert limiter.allow("k")
@@ -24,22 +33,56 @@ def test_sliding_window_blocks_after_limit(monkeypatch: pytest.MonkeyPatch) -> N
assert not limiter.allow("k") assert not limiter.allow("k")
assert limiter.allow("other") assert limiter.allow("other")
now[0] += 61 clock[0] += 61
assert limiter.allow("k") assert limiter.allow("k")
def test_request_limiter_per_ip_and_token(monkeypatch: pytest.MonkeyPatch) -> None: def test_idle_keys_are_evicted(clock: list[float]) -> None:
monkeypatch.setenv("GITEA_URL", "https://g.example.com") limiter = SlidingWindowLimiter(max_requests=5, window_seconds=60)
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "2") for index in range(100):
limiter = RequestRateLimiter() limiter.allow(f"client-{index}")
assert len(limiter) == 100
clock[0] += 61
limiter.allow("fresh")
assert len(limiter) == 1
def test_key_count_is_capped_and_least_recently_used_keys_go_first(clock: list[float]) -> None:
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60, max_keys=3)
for key in ("a", "b", "c"):
limiter.allow(key)
limiter.allow("a") # touch: "b" is now the oldest
limiter.allow("d")
assert len(limiter) == 3
assert limiter.allow("b") # evicted earlier, so it starts with a clean window
assert not limiter.allow("d")
def test_first_excess_reports_once_per_window(clock: list[float]) -> None:
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60)
limiter.allow("k")
assert not limiter.allow("k") and limiter.first_excess("k")
assert not limiter.allow("k") and not limiter.first_excess("k")
clock[0] += 30
assert not limiter.allow("k") and not limiter.first_excess("k")
clock[0] += 31
assert limiter.allow("k") # the old event left the window
assert not limiter.allow("k") and limiter.first_excess("k")
def test_request_limiter_per_ip_and_token(mock_env: None) -> None:
limiter = RequestRateLimiter(server_settings(rate_limit_per_minute=2))
assert limiter.check("1.1.1.1", "tok").allowed assert limiter.check("1.1.1.1", "tok").allowed
assert limiter.check("1.1.1.1", None).allowed assert limiter.check("1.1.1.1", None).allowed
assert not limiter.check("1.1.1.1", "tok").allowed assert not limiter.check("1.1.1.1", "tok").allowed
# A different IP with the same token trips the per-token limiter. # The same token from other IPs trips the per-token limiter.
other = limiter.check("2.2.2.2", "tok") assert limiter.check("2.2.2.2", "tok").allowed
assert other.allowed or other.reason == "Per-token rate limit exceeded"
third = limiter.check("3.3.3.3", "tok") third = limiter.check("3.3.3.3", "tok")
assert not third.allowed assert not third.allowed
assert third.reason == "Per-token rate limit exceeded" assert third.reason == "Per-token rate limit exceeded"
@@ -48,8 +91,30 @@ def test_request_limiter_per_ip_and_token(monkeypatch: pytest.MonkeyPatch) -> No
assert valid, errors assert valid, errors
def test_global_limiter_singleton(mock_env: None) -> None: def test_only_the_first_excess_per_key_is_audited(mock_env: None, clock: list[float]) -> None:
first = get_rate_limiter() limiter = RequestRateLimiter(server_settings(rate_limit_per_minute=1))
assert get_rate_limiter() is first limiter.check("1.1.1.1", None)
reset_rate_limiter() for _ in range(20):
assert get_rate_limiter() is not first assert not limiter.check("1.1.1.1", None).allowed
events = security_events()
assert [e["event_type"] for e in events] == ["rate_limit_ip_exceeded"]
clock[0] += 61
limiter.check("1.1.1.1", None)
assert not limiter.check("1.1.1.1", None).allowed
assert len(security_events()) == 2
def test_unwritable_audit_log_does_not_break_rate_limiting(
mock_env: None, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def broken() -> None:
raise OSError("read-only file system")
monkeypatch.setattr("aegis_gitea_mcp.rate_limit.get_audit_logger", broken)
limiter = RequestRateLimiter(server_settings(rate_limit_per_minute=1))
limiter.check("1.1.1.1", None)
assert not limiter.check("1.1.1.1", None).allowed
assert "OSError" in caplog.text
+65 -1
View File
@@ -469,7 +469,7 @@ async def test_audit_event_per_call_never_contains_argument_values(
assert validate_audit_log_integrity(log_path) == (True, []) assert validate_audit_log_integrity(log_path) == (True, [])
async def test_audit_failure_does_not_break_the_call( async def test_audit_write_failure_after_execution_does_not_break_the_call(
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
mock_user(httpx_mock) mock_user(httpx_mock)
@@ -482,11 +482,75 @@ async def test_audit_failure_does_not_break_the_call(
async with connected(audit=BrokenAudit()) as client: # type: ignore[arg-type] async with connected(audit=BrokenAudit()) as client: # type: ignore[arg-type]
result = await client.call_tool("gitea_repo_get", REPO_ARGS) result = await client.call_tool("gitea_repo_get", REPO_ARGS)
# The call already ran (its side effect cannot be undone), so the result still goes out.
assert not result.is_error assert not result.is_error
assert "OSError" in caplog.text assert "OSError" in caplog.text
assert GITEA_TOKEN not in caplog.text assert GITEA_TOKEN not in caplog.text
async def test_unavailable_audit_log_refuses_the_call_before_gitea_is_contacted(
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def broken() -> None:
raise OSError("permission denied: " + GITEA_TOKEN)
monkeypatch.setattr("aegis_gitea_mcp.server.get_audit_logger", broken)
async with connected() as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
written = await client.call_tool("gitea_issue_create_issue", {**REPO_ARGS, "body": {}})
assert result.is_error and written.is_error
error = payload_of(result)["error"]
assert error["kind"] == "audit_unavailable"
assert GITEA_TOKEN not in json.dumps(payload_of(result)) + caplog.text
assert httpx_mock.get_requests() == []
async def test_audit_correlation_id_is_the_request_id(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
async with connected() as client:
await client.call_tool("gitea_repo_get", REPO_ARGS)
from aegis_gitea_mcp.audit import get_audit_logger
line = get_audit_logger().log_path.read_text(encoding="utf-8").splitlines()[-1]
entry = json.loads(line)
assert entry["payload"]["correlation_id"] == entry["request_id"] != "-"
async def test_rejected_raw_call_audits_no_unvalidated_path_or_sudo() -> None:
secret = "SECRET-IN-PATH-9f8e7d6c5b"
async with connected() as client:
bad_path = await client.call_tool(
"gitea_api_request", {"method": "GET", "path": secret, "sudo": secret}
)
bad_sudo = await client.call_tool(
"gitea_api_request", {"method": "GET", "path": "/user", "sudo": "bad name!"}
)
assert bad_path.is_error and bad_sudo.is_error
first, second = audit_entries()
assert first["outcome"] == second["outcome"] == "invalid_argument"
for entry in (first, second):
assert "path" not in entry and "sudo" not in entry
from aegis_gitea_mcp.audit import get_audit_logger
assert secret not in get_audit_logger().log_path.read_text(encoding="utf-8")
async def test_raw_call_audits_the_validated_method_and_path(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/version", json={})
async with connected() as client:
await client.call_tool(
"gitea_api_request", {"method": "GET", "path": "/version", "sudo": "alice"}
)
entry = audit_entries()[-1]
assert (entry["method"], entry["path"], entry["sudo"]) == ("GET", "/version", "alice")
# Internal errors # Internal errors
+18 -11
View File
@@ -25,6 +25,7 @@ from aegis_gitea_mcp.gitea import RAW_TOOL_NAME
from aegis_gitea_mcp.identity import Identity from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.transports import ( from aegis_gitea_mcp.transports import (
GiteaProbe, GiteaProbe,
RateLimitMiddleware,
build_http_app, build_http_app,
transport_security, transport_security,
) )
@@ -336,11 +337,7 @@ def test_transport_security_wildcard_bind_adds_no_bind_host() -> None:
# Rate limiting # Rate limiting
def test_rate_limit_returns_429_on_the_mcp_path_only( def test_rate_limit_returns_429_on_the_mcp_path_only(httpx_mock: HTTPXMock) -> None:
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
# The limiter reads the process-wide settings.
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "2")
httpx_mock.add_response(url=f"{API}/version", json={}, is_reusable=True) httpx_mock.add_response(url=f"{API}/version", json={}, is_reusable=True)
settings = http_settings(rate_limit_per_minute=2) settings = http_settings(rate_limit_per_minute=2)
with TestClient(build_http_app(settings), base_url=BASE) as c: with TestClient(build_http_app(settings), base_url=BASE) as c:
@@ -357,22 +354,32 @@ def test_rate_limit_returns_429_on_the_mcp_path_only(
assert health.status_code == 200 assert health.status_code == 200
def test_rate_limit_is_keyed_per_token(monkeypatch: pytest.MonkeyPatch) -> None: def test_rate_limit_is_keyed_per_token_hash() -> None:
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "1")
settings = http_settings(rate_limit_per_minute=1) settings = http_settings(rate_limit_per_minute=1)
with TestClient(build_http_app(settings), base_url=BASE) as c: app = build_http_app(settings)
with TestClient(app, base_url=BASE) as c:
first = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()}) first = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
limiter = next(m for m in app.user_middleware if m.cls is RateLimitMiddleware).kwargs["limiter"]
assert first.status_code == 200 assert first.status_code == 200
from aegis_gitea_mcp.rate_limit import get_rate_limiter
limiter = get_rate_limiter()
assert limiter._token_limiter.max_requests == 1 assert limiter._token_limiter.max_requests == 1
# The raw header is never used as a key, only its hash. # The raw header is never used as a key, only its hash.
keys = list(limiter._token_limiter._events) keys = list(limiter._token_limiter._events)
assert keys and all(BEARER_TOKEN not in key for key in keys) assert keys and all(BEARER_TOKEN not in key for key in keys)
def test_rate_limit_uses_the_settings_passed_to_the_app(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "500") # the process-wide value must not matter
app = build_http_app(http_settings(rate_limit_per_minute=1))
with TestClient(app, base_url=BASE) as c:
statuses = [
c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()}).status_code
for _ in range(2)
]
assert statuses == [200, 429]
# Auth wiring # Auth wiring
+98 -5
View File
@@ -95,14 +95,107 @@ def test_loopback_without_auth_runs(uvicorn_spy: UvicornSpy) -> None:
assert uvicorn_spy.calls[0][1]["log_level"] == "debug" assert uvicorn_spy.calls[0][1]["log_level"] == "debug"
def test_forwarded_allow_ips_setting_is_honoured_when_present( def test_forwarded_allow_ips_setting_is_passed_to_uvicorn(uvicorn_spy: UvicornSpy) -> None:
uvicorn_spy: UvicornSpy, monkeypatch: pytest.MonkeyPatch assert run_http(server_settings(forwarded_allow_ips="10.0.0.0/8, 172.18.0.2")) == 0
) -> None:
monkeypatch.setattr(Settings, "forwarded_allow_ips", "10.0.0.0/8", raising=False)
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "10.0.0.0/8,172.18.0.2"
def test_forwarded_allow_ips_wildcard_needs_an_explicit_value_and_warns(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert server_settings().forwarded_allow_ips == "127.0.0.1"
assert run_http(server_settings(forwarded_allow_ips="*")) == 0
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "*"
assert "FORWARDED_ALLOW_IPS=*" in capsys.readouterr().err
def test_non_loopback_bind_without_public_host_warns_which_variables_to_set(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings(mcp_host=ANY_HOST, mcp_bearer_token=BEARER_TOKEN)) == 0
err = capsys.readouterr().err
assert "PUBLIC_BASE_URL" in err and "ALLOWED_HOSTS" in err and "421" in err
@pytest.mark.parametrize(
"extra",
[{"public_base_url": "https://mcp.example.com"}, {"allowed_hosts": ["mcp.example.com"]}],
)
def test_no_host_warning_when_a_public_host_is_configured(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str], extra: dict[str, Any]
) -> None:
settings = server_settings(mcp_host=ANY_HOST, mcp_bearer_token=BEARER_TOKEN, **extra)
assert run_http(settings) == 0
assert "421" not in capsys.readouterr().err
def test_no_host_warning_on_loopback(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings()) == 0 assert run_http(server_settings()) == 0
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "10.0.0.0/8" assert capsys.readouterr().err == ""
def test_oauth_enabled_with_a_bearer_warns_that_oauth_is_unavailable(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0
assert "per-user OAuth is not available" in capsys.readouterr().err
@pytest.mark.parametrize("value", ["*,10.0.0.1", "not-an-ip", "10.0.0.1/99"])
def test_invalid_forwarded_allow_ips_is_rejected(value: str) -> None:
with pytest.raises(ValueError, match="FORWARDED_ALLOW_IPS"):
server_settings(forwarded_allow_ips=value)
def test_blank_forwarded_allow_ips_means_the_default() -> None:
assert server_settings(forwarded_allow_ips=" ").forwarded_allow_ips == "127.0.0.1"
# Audit log at startup
@pytest.fixture
def unwritable_audit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""An audit path whose parent is a regular file, so it can never be opened."""
blocker = tmp_path / "blocker"
blocker.write_text("x", encoding="utf-8")
path = blocker / "audit.log"
monkeypatch.setenv("AUDIT_LOG_PATH", str(path))
return path
def test_http_exits_2_when_the_audit_log_cannot_be_opened(
uvicorn_spy: UvicornSpy, unwritable_audit: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings(audit_log_path=unwritable_audit)) == 2
captured = capsys.readouterr()
assert uvicorn_spy.calls == []
assert "audit log" in captured.err and "AUDIT_LOG_PATH" in captured.err
assert str(unwritable_audit) in captured.err
assert captured.out == ""
def test_stdio_exits_2_when_the_audit_log_cannot_be_opened(
unwritable_audit: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(transports.anyio, "run", lambda *a: pytest.fail("must not serve"))
assert run_stdio(server_settings(audit_log_path=unwritable_audit)) == 2
captured = capsys.readouterr()
assert "AUDIT_LOG_PATH" in captured.err
assert captured.out == ""
def test_oauth_misconfiguration_lists_every_missing_setting( def test_oauth_misconfiguration_lists_every_missing_setting(