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

+35 -13
View File
@@ -347,6 +347,7 @@ class _Record:
"""Everything one audit entry needs, filled in as the call progresses."""
tool: str
request_id: str = ""
domain: str | None = None
method: str | None = None
path: str | None = None
@@ -405,10 +406,24 @@ class _Dispatcher:
) -> types.CallToolResult:
"""Handle ``tools/call``; every call produces exactly one audit entry."""
started = time.perf_counter()
set_request_id(uuid.uuid4().hex)
request_id = uuid.uuid4().hex
set_request_id(request_id)
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]
# 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:
entry = self._registry.get(params.name)
if entry is None:
@@ -426,7 +441,7 @@ class _Dispatcher:
message += f" ({type(exc).__name__})"
return _result(_error_payload("internal_error", message), is_error=True)
finally:
self._audit(record, (time.perf_counter() - started) * 1000)
self._audit(audit, record, (time.perf_counter() - started) * 1000)
async def _dispatch(
self, entry: _Entry, arguments: dict[str, Any], record: _Record
@@ -434,20 +449,19 @@ class _Dispatcher:
operation = entry.operation
if operation is not None:
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)
if invalid is None and operation is not None:
invalid = _upload_cap_error(operation, arguments, self._settings.max_binary_bytes)
if invalid is not None:
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":
message = "READ_ONLY mode is on: gitea_api_request only accepts GET"
return self._failed(record, "read_only", _error_payload("read_only", message))
@@ -492,15 +506,23 @@ class _Dispatcher:
record.outcome = "success"
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}
if record.http_status is not None:
params["http_status"] = record.http_status
try:
audit = self._audit_override or get_audit_logger()
audit.log_tool_invocation(
record.tool,
params=params,
correlation_id=record.request_id,
result_status="error" if record.is_error else "success",
domain=record.domain,
method=record.method,