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
+532 -115

No files matched your search

+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
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
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_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. |
| `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)
@@ -50,7 +51,7 @@ when OAuth is on.
| 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. |
| `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
@@ -74,7 +75,7 @@ listed nor callable.
| `SECRET_DETECTION_MODE` | `mask` | `off`, `mask` or `block`. See below. |
| `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. |
| `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`
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
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
+1
View File
@@ -40,6 +40,7 @@ authentication), but it does not decide what is safe. This checklist is how you
rejected.
- Restrict who can reach the endpoint at the proxy or firewall too (VPN, allow list), as a
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.
- 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`. |
| `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. |
| `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. |
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.
- 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.
- Request rejected with an invalid Host: the name you used is not allowed. Add it to
`ALLOWED_HOSTS` or set `PUBLIC_BASE_URL`.
- 429: the client exceeded `RATE_LIMIT_PER_MINUTE`.
- Request rejected with an invalid Host (421): the name you used is not allowed. Add it to
`ALLOWED_HOSTS` or set `PUBLIC_BASE_URL`. With a non-loopback `MCP_HOST` and neither variable
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;`.
## OAuth sign-in