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
+532
-115
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user