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:
1 parent
a3222d5ebe
commit
cf3d2e17d3
16 files changed
+530
-113
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user