fix: redact stored webhook authorization headers and other secret fields by key name (SEC-02)

This commit is contained in:
Latte committed 2026-10-14 17:08:59 +02:00
1 parent d5ca530db3
commit 5b622e64e2
4 files changed
+201 -2

No files matched your search

+8
View File
@@ -96,6 +96,14 @@ _SENSITIVE_BODY_KEYS = frozenset(
)
_MIN_SENSITIVE_VALUE_CHARS = 4
#: Response fields whose non-empty string values are replaced with ``[REDACTED]``
#: in every result except those of credential-issuing operations (whose purpose
#: is to return a credential). Gitea fills ``authorization_header`` on every
#: webhook read, so a stored header would otherwise reach the model. Keep this
#: next to :data:`_SENSITIVE_BODY_KEYS` so a new secret-bearing field is added
#: in one place.
SENSITIVE_RESPONSE_KEYS = _SENSITIVE_BODY_KEYS
class ArgumentError(ValueError):
"""An argument cannot be turned into a safe request (message never echoes the value)."""
+14 -1
View File
@@ -13,6 +13,9 @@ Security properties (all covered by tests):
* the identity token and ``Identity.redact``, the configured Gitea token, the MCP bearer token, the
OAuth client secret, ``extra_secrets`` and secret values submitted in a
request body are replaced with ``[REDACTED]`` in every result, always;
* secret-bearing response fields (:data:`~.encoding.SENSITIVE_RESPONSE_KEYS`, for example a
webhook's stored ``authorization_header``) are redacted by key name in every result except
those of :data:`CREDENTIAL_ISSUING_OPERATIONS`, whatever ``SECRET_DETECTION_MODE`` says;
* secret-pattern masking/blocking follows ``SECRET_DETECTION_MODE``, except for
:data:`CREDENTIAL_ISSUING_OPERATIONS` whose purpose is to return a freshly
minted credential (exact redaction still applies there);
@@ -38,6 +41,7 @@ from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.errors import kind_for_status, sanitize_message
from aegis_gitea_mcp.gitea.encoding import (
RAW_METHODS,
SENSITIVE_RESPONSE_KEYS,
ArgumentError,
PayloadTooLargeError,
RequestBody,
@@ -50,7 +54,12 @@ from aegis_gitea_mcp.gitea.encoding import (
validate_sudo,
)
from aegis_gitea_mcp.gitea.pagination import build_pagination
from aegis_gitea_mcp.gitea.redaction import normalize_secrets, redact_exact, redact_text
from aegis_gitea_mcp.gitea.redaction import (
normalize_secrets,
redact_exact,
redact_fields,
redact_text,
)
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.security import sanitize_data
@@ -613,6 +622,10 @@ class GiteaExecutor:
"""Redact, mask or block, and bound the payload size."""
secrets = self._secrets(identity, call)
payload = redact_exact(payload, secrets)
if not exempt and "data" in payload:
# Stored secrets (webhook authorization headers, ...) by field name: pattern
# masking cannot recognise them and SECRET_DETECTION_MODE may be off.
payload = {**payload, "data": redact_fields(payload["data"], SENSITIVE_RESPONSE_KEYS)}
mode = self._settings.secret_detection_mode
if mode != "off" and not exempt:
if mode == "block":
+34 -1
View File
@@ -7,7 +7,7 @@ they have, in every string of a payload (values and dict keys).
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Collection, Iterable
from typing import Any
REDACTED = "[REDACTED]"
@@ -41,3 +41,36 @@ def redact_exact(value: Any, secrets: tuple[str, ...]) -> Any:
if isinstance(value, (list, tuple)):
return [redact_exact(item, secrets) for item in value]
return value
def redact_fields(value: Any, keys: Collection[str], *, max_depth: int = 128) -> Any:
"""Replace the string values of secret-bearing fields, by key name, at any depth.
A non-empty string under a key in ``keys`` (compared lower-cased) becomes
``[REDACTED]``; an empty value stays so the presence of the field is still
visible. Containers under such a key are searched like any other value.
Nesting deeper than ``max_depth`` is returned unchanged (the executor bounds
JSON depth before this runs).
"""
def walk(item: Any, depth: int) -> Any:
if depth > max_depth:
return item
if isinstance(item, dict):
return {
key: (
REDACTED
if isinstance(key, str) and key.lower() in keys and _is_secret_value(sub)
else walk(sub, depth + 1)
)
for key, sub in item.items()
}
if isinstance(item, (list, tuple)):
return [walk(sub, depth + 1) for sub in item]
return item
return walk(value, 0)
def _is_secret_value(value: Any) -> bool:
return isinstance(value, str) and bool(value)