fix: key slash encoding on route, redact more request secrets, bound JSON depth and uploads
Slash safety is decided per (path template, parameter) so single-segment
routes such as releases/tags/{tag} and editorconfig/{filepath} keep %2F.
Adds authorization_header, remote_password, mirror_password, mirror_token and
the AWS key fields to the redacted request fields. Deeply nested JSON from
Gitea is treated as text instead of raising RecursionError, and base64
uploads are capped before decoding.
This commit is contained in:
1 parent
1a039e32ab
commit
156e963e21
4 files changed
+402
-21
No files matched your search
@@ -9,11 +9,14 @@ Path safety rules
|
||||
Every value substituted into a path template (and every raw ``request()``
|
||||
path) is rejected when it is empty, contains a ``..`` or ``.`` path segment, an
|
||||
empty segment, a backslash, ``?``, ``#``, ``%`` or a control character. After
|
||||
that check the value is percent-encoded. Most parameters are quoted with
|
||||
``safe=""`` so a ``/`` becomes ``%2F`` and can never open a new path segment.
|
||||
The parameters in :data:`SLASH_PATH_PARAMS` legitimately hold slashes (file
|
||||
paths, branch, tag and ref names) and are routed by Gitea through wildcard
|
||||
routes, so they are quoted with ``safe="/"``.
|
||||
that check the value is percent-encoded. Parameters are quoted with
|
||||
``safe=""`` so a ``/`` becomes ``%2F`` and can never open a new path segment,
|
||||
except the ``(path template, parameter)`` pairs in :data:`SLASH_ROUTES`.
|
||||
Those are routed by Gitea 28.1.0 through wildcard routes (file paths, branch,
|
||||
tag and ref names) and only resolve with real slashes, so they are quoted with
|
||||
``safe="/"``. The decision is keyed on the route and not on the parameter name
|
||||
because the same name is a single segment elsewhere (``releases/tags/{tag}`` and
|
||||
``editorconfig/{filepath}`` answer 404 for a raw slash and only accept ``%2F``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,12 +32,32 @@ from urllib.parse import quote
|
||||
|
||||
from aegis_gitea_mcp.catalog.model import Operation, Parameter
|
||||
|
||||
#: Path parameters that may contain ``/``. Gitea serves these routes with
|
||||
#: wildcard patterns (``contents/*``, ``branches/*``, ``compare/*``,
|
||||
#: ``git/refs/*``, ``tags/*``, ``pulls/{base}/*``), so refs such as
|
||||
#: ``feature/login`` and file paths such as ``src/app/main.py`` are sent with
|
||||
#: real slashes. Every other parameter is quoted with ``safe=""``.
|
||||
SLASH_PATH_PARAMS = frozenset({"filepath", "branch", "ref", "basehead", "head", "tag"})
|
||||
#: ``(path template, parameter name)`` pairs that Gitea 28.1.0 serves through a
|
||||
#: wildcard route, verified against a live instance: a raw ``/`` resolves and
|
||||
#: ``%2F`` would not. Everything else (including same-named parameters on
|
||||
#: single-segment routes such as ``releases/tags/{tag}`` and
|
||||
#: ``editorconfig/{filepath}``) is quoted with ``safe=""``.
|
||||
SLASH_ROUTES = frozenset(
|
||||
{
|
||||
("/repos/{owner}/{repo}/branches/{branch}", "branch"),
|
||||
("/repos/{owner}/{repo}/tags/{tag}", "tag"),
|
||||
("/repos/{owner}/{repo}/contents/{filepath}", "filepath"),
|
||||
("/repos/{owner}/{repo}/contents-ext/{filepath}", "filepath"),
|
||||
("/repos/{owner}/{repo}/raw/{filepath}", "filepath"),
|
||||
("/repos/{owner}/{repo}/media/{filepath}", "filepath"),
|
||||
("/repos/{owner}/{repo}/compare/{basehead}", "basehead"),
|
||||
("/repos/{owner}/{repo}/git/refs/{ref}", "ref"),
|
||||
("/repos/{owner}/{repo}/commits/{ref}/status", "ref"),
|
||||
("/repos/{owner}/{repo}/commits/{ref}/statuses", "ref"),
|
||||
("/repos/{owner}/{repo}/pulls/{base}/{head}", "head"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def allows_slash(template: str, name: str) -> bool:
|
||||
"""Whether ``name`` in the path ``template`` is a wildcard that takes raw slashes."""
|
||||
return (template, name) in SLASH_ROUTES
|
||||
|
||||
|
||||
#: Query parameter names that carry credentials or switch the acting user in
|
||||
#: Gitea. They are never forwarded: the identity and the ``sudo`` argument are
|
||||
@@ -63,6 +86,12 @@ _SENSITIVE_BODY_KEYS = frozenset(
|
||||
"auth_password",
|
||||
"auth_token",
|
||||
"private_key",
|
||||
"authorization_header",
|
||||
"remote_password",
|
||||
"mirror_password",
|
||||
"mirror_token",
|
||||
"aws_secret_access_key",
|
||||
"aws_access_key_id",
|
||||
}
|
||||
)
|
||||
_MIN_SENSITIVE_VALUE_CHARS = 4
|
||||
@@ -72,6 +101,10 @@ class ArgumentError(ValueError):
|
||||
"""An argument cannot be turned into a safe request (message never echoes the value)."""
|
||||
|
||||
|
||||
class PayloadTooLargeError(ArgumentError):
|
||||
"""A request payload is larger than the configured limit (maps to ``payload_too_large``)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestBody:
|
||||
"""Body of an outgoing request; at most one of the fields is set.
|
||||
@@ -151,7 +184,7 @@ def render_path(operation: Operation, arguments: Mapping[str, Any]) -> tuple[str
|
||||
raise ArgumentError(f"path placeholder '{name}' has no matching parameter")
|
||||
value = _path_value(parameter, arguments)
|
||||
check_segments(value, f"path parameter '{parameter.arg_name}'")
|
||||
safe = "/" if name in SLASH_PATH_PARAMS else ""
|
||||
safe = "/" if allows_slash(operation.path, name) else ""
|
||||
display[name] = value
|
||||
return quote(value, safe=safe)
|
||||
|
||||
@@ -247,8 +280,14 @@ def _has_control(value: str) -> bool:
|
||||
return any(unicodedata.category(char) in _BAD_CATEGORIES for char in value)
|
||||
|
||||
|
||||
def _decoded_size_bound(encoded: str) -> int:
|
||||
"""Decoded byte count implied by a base64 string's length (before decoding it)."""
|
||||
padding = len(encoded) - len(encoded.rstrip("="))
|
||||
return max(0, len(encoded) * 3 // 4 - min(padding, 2))
|
||||
|
||||
|
||||
def _file_part(
|
||||
parameter: Parameter, value: Any
|
||||
parameter: Parameter, value: Any, max_file_bytes: int | None
|
||||
) -> tuple[str, tuple[str | None, bytes, str | None]]:
|
||||
label = f"file argument '{parameter.arg_name}'"
|
||||
if not isinstance(value, Mapping):
|
||||
@@ -263,6 +302,8 @@ def _file_part(
|
||||
filename = _single_line(filename, f"{label} filename", _MAX_FILENAME_CHARS)
|
||||
if '"' in filename:
|
||||
raise ArgumentError(f"{label} filename must not contain a double quote")
|
||||
if max_file_bytes is not None and _decoded_size_bound(encoded) > max_file_bytes:
|
||||
raise PayloadTooLargeError(f"{label} is larger than the limit of {max_file_bytes} bytes")
|
||||
try:
|
||||
content = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
@@ -275,14 +316,23 @@ def _file_part(
|
||||
return parameter.name, (filename, content, content_type or "application/octet-stream")
|
||||
|
||||
|
||||
def build_body(operation: Operation, arguments: Mapping[str, Any]) -> RequestBody:
|
||||
def build_body(
|
||||
operation: Operation, arguments: Mapping[str, Any], max_file_bytes: int | None = None
|
||||
) -> RequestBody:
|
||||
"""Build the request body from the operation's body and formData parameters.
|
||||
|
||||
``formData`` parameters produce a multipart body (files are decoded from
|
||||
base64 strictly). A ``body`` parameter is sent as JSON, or as a plain string
|
||||
when the operation only consumes ``text/plain``.
|
||||
|
||||
Args:
|
||||
operation: Catalog operation.
|
||||
arguments: Tool arguments keyed by ``arg_name``.
|
||||
max_file_bytes: Largest decoded size accepted for one file argument; the
|
||||
base64 length is checked before anything is decoded. None disables it.
|
||||
|
||||
Raises:
|
||||
PayloadTooLargeError: A file argument is larger than ``max_file_bytes``.
|
||||
ArgumentError: A file argument or text body is malformed.
|
||||
"""
|
||||
form_parts: list[tuple[str, tuple[str | None, bytes, str | None]]] = []
|
||||
@@ -293,7 +343,7 @@ def build_body(operation: Operation, arguments: Mapping[str, Any]) -> RequestBod
|
||||
if value is None:
|
||||
continue
|
||||
if parameter.is_file:
|
||||
form_parts.append(_file_part(parameter, value))
|
||||
form_parts.append(_file_part(parameter, value, max_file_bytes))
|
||||
else:
|
||||
form_parts.append(
|
||||
(parameter.name, (None, _scalar(value, parameter.arg_name).encode(), None))
|
||||
|
||||
Reference in new issue
Block a user