Files
AegisGitea-MCP/src/aegis_gitea_mcp/gitea/encoding.py
T

415 lines
16 KiB
Python

"""Request building for the Gitea executor: paths, query strings and bodies.
Everything here is pure (no I/O) and raises :class:`ArgumentError` with a
message that never contains the offending value, because a rejected value may
itself be a credential a model pasted into the wrong argument.
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. 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
import base64
import binascii
import re
import unicodedata
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote
from aegis_gitea_mcp.catalog.model import Operation, Parameter
#: ``(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
#: the only sanctioned ways to set them.
FORBIDDEN_QUERY_NAMES = frozenset({"token", "access_token", "sudo", "_sudo"})
#: Raw-request methods.
RAW_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
_PLACEHOLDER = re.compile(r"\{([^{}]+)\}")
_BAD_CHARS = frozenset("\\?#%")
_BAD_CATEGORIES = frozenset({"Cc", "Cs", "Zl", "Zp"})
_SUDO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,255}$")
_MAX_FILENAME_CHARS = 255
_MAX_CONTENT_TYPE_CHARS = 255
_BODY_DEPTH_LIMIT = 8
#: Body keys whose string values are treated as secrets for redaction.
_SENSITIVE_BODY_KEYS = frozenset(
{
"password",
"secret",
"client_secret",
"token",
"access_token",
"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
#: 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)."""
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.
Attributes:
json: Value to send as ``application/json`` (``has_json`` tells ``None`` apart).
has_json: True when ``json`` is the body.
content: Raw bytes for ``text/plain`` bodies.
content_type: Content type for ``content`` bodies.
files: Multipart parts as ``(field, (filename|None, bytes, content_type|None))``.
"""
json: Any = None
has_json: bool = False
content: bytes | None = None
content_type: str | None = None
files: list[tuple[str, tuple[str | None, bytes, str | None]]] = field(default_factory=list)
def _has_bad_character(value: str) -> bool:
return any(
char in _BAD_CHARS or unicodedata.category(char) in _BAD_CATEGORIES for char in value
)
def check_segments(value: str, label: str) -> None:
"""Reject values that could escape or reshape a URL path.
Args:
value: Candidate path value (a parameter value or a whole raw path).
label: Name used in the error message.
Raises:
ArgumentError: The value is empty or unsafe.
"""
if not value:
raise ArgumentError(f"{label} must not be empty")
if _has_bad_character(value):
raise ArgumentError(
f"{label} contains a forbidden character (backslash, ?, #, % or control character)"
)
for segment in value.split("/"):
if segment in {"", ".", ".."}:
raise ArgumentError(f"{label} contains an empty, '.' or '..' path segment")
def _path_value(parameter: Parameter, arguments: Mapping[str, Any]) -> str:
value = arguments.get(parameter.arg_name)
if value is None:
raise ArgumentError(f"missing path parameter '{parameter.arg_name}'")
if isinstance(value, bool) or not isinstance(value, (str, int)):
raise ArgumentError(f"path parameter '{parameter.arg_name}' must be a string or integer")
return str(value)
def render_path(operation: Operation, arguments: Mapping[str, Any]) -> tuple[str, str]:
"""Fill the operation's path template.
Args:
operation: Catalog operation.
arguments: Tool arguments keyed by ``arg_name``.
Returns:
``(encoded_path, display_path)``: the percent-encoded path to send and
the readable path (raw values) for the result envelope.
Raises:
ArgumentError: A placeholder has no parameter, is missing, or is unsafe.
"""
by_name = {p.name: p for p in operation.parameters if p.location == "path"}
display: dict[str, str] = {}
def fill(match: re.Match[str]) -> str:
name = match.group(1)
parameter = by_name.get(name)
if parameter is None:
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 allows_slash(operation.path, name) else ""
display[name] = value
return quote(value, safe=safe)
encoded_path = _PLACEHOLDER.sub(fill, operation.path)
display_path = _PLACEHOLDER.sub(lambda m: display[m.group(1)], operation.path)
return encoded_path, display_path
def render_raw_path(path: str) -> tuple[str, str]:
"""Validate and encode a raw ``/api/v1``-relative path.
Returns:
``(encoded_path, display_path)``.
Raises:
ArgumentError: The path does not start with ``/`` or fails the segment checks.
"""
if not isinstance(path, str) or not path.startswith("/"):
raise ArgumentError("path must start with '/' and be relative to /api/v1")
check_segments(path[1:], "path")
return quote(path, safe="/"), path
def _scalar(value: Any, name: str) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
return value
raise ArgumentError(f"query parameter '{name}' must be a string, number, boolean or list")
def _query_pairs(name: str, value: Any, csv: bool) -> list[tuple[str, str]]:
if name.lower() in FORBIDDEN_QUERY_NAMES:
raise ArgumentError(f"query parameter '{name}' is reserved and cannot be set")
if isinstance(value, (list, tuple)):
items = [_scalar(item, name) for item in value]
if not items:
return []
if csv:
return [(name, ",".join(items))]
return [(name, item) for item in items]
return [(name, _scalar(value, name))]
def build_query(operation: Operation, arguments: Mapping[str, Any]) -> list[tuple[str, str]]:
"""Build the query string pairs from the operation's query parameters.
Only provided (non-null) arguments are sent. Booleans become ``true`` or
``false``; arrays become repeated parameters unless the parameter schema
declares ``collectionFormat``/``x-collection-format`` ``csv``.
"""
pairs: list[tuple[str, str]] = []
for parameter in operation.parameters:
if parameter.location != "query":
continue
value = arguments.get(parameter.arg_name)
if value is None:
continue
schema = parameter.schema
csv = "csv" in (schema.get("collectionFormat"), schema.get("x-collection-format"))
pairs.extend(_query_pairs(parameter.name, value, csv))
return pairs
def build_raw_query(query: Mapping[str, Any] | None) -> list[tuple[str, str]]:
"""Build query pairs for a raw request (repeated parameters for lists)."""
pairs: list[tuple[str, str]] = []
for name, value in (query or {}).items():
if value is None:
continue
pairs.extend(_query_pairs(str(name), value, csv=False))
return pairs
def validate_sudo(sudo: Any) -> str | None:
"""Validate the optional ``sudo`` login (None when not given)."""
if sudo is None:
return None
# fullmatch: ``$`` would also accept a trailing newline.
if not isinstance(sudo, str) or not _SUDO_PATTERN.fullmatch(sudo):
raise ArgumentError("sudo must be a Gitea username (letters, digits, '-', '_', '.')")
return sudo
def _single_line(value: str, label: str, limit: int) -> str:
if not value or len(value) > limit or _has_control(value):
raise ArgumentError(f"{label} must be a non-empty single-line string")
return value
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, 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):
raise ArgumentError(f"{label} must be an object with filename and content_base64")
unknown = set(value) - {"filename", "content_base64", "content_type"}
if unknown:
raise ArgumentError(f"{label} has unexpected keys")
filename = value.get("filename")
encoded = value.get("content_base64")
if not isinstance(filename, str) or not isinstance(encoded, str):
raise ArgumentError(f"{label} needs string filename and content_base64")
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:
raise ArgumentError(f"{label} content_base64 is not valid base64") from exc
content_type = value.get("content_type")
if content_type is not None:
if not isinstance(content_type, str):
raise ArgumentError(f"{label} content_type must be a string")
content_type = _single_line(content_type, f"{label} content_type", _MAX_CONTENT_TYPE_CHARS)
return parameter.name, (filename, content, content_type or "application/octet-stream")
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]]] = []
for parameter in operation.parameters:
if parameter.location != "formData":
continue
value = arguments.get(parameter.arg_name)
if value is None:
continue
if parameter.is_file:
form_parts.append(_file_part(parameter, value, max_file_bytes))
else:
form_parts.append(
(parameter.name, (None, _scalar(value, parameter.arg_name).encode(), None))
)
if form_parts:
return RequestBody(files=form_parts)
for parameter in operation.parameters:
if parameter.location != "body":
continue
value = arguments.get(parameter.arg_name)
if value is None:
continue
if "text/plain" in operation.consumes and "application/json" not in operation.consumes:
if not isinstance(value, str):
raise ArgumentError(f"body of '{parameter.arg_name}' must be a string")
return RequestBody(content=value.encode("utf-8"), content_type="text/plain")
return RequestBody(json=value, has_json=True)
return RequestBody()
def _walk_sensitive(value: Any, found: list[str], depth: int) -> None:
if depth > _BODY_DEPTH_LIMIT:
return
if isinstance(value, Mapping):
for key, item in value.items():
if (
isinstance(key, str)
and key.lower() in _SENSITIVE_BODY_KEYS
and isinstance(item, str)
and len(item) >= _MIN_SENSITIVE_VALUE_CHARS
):
found.append(item)
else:
_walk_sensitive(item, found, depth + 1)
elif isinstance(value, (list, tuple)):
for item in value:
_walk_sensitive(item, found, depth + 1)
def sensitive_body_values(body: Any, *, secret_data: bool) -> list[str]:
"""Collect submitted values that must never be echoed back in a result.
Args:
body: JSON request body.
secret_data: True for Action-secret writes, where ``body["data"]`` is the
secret itself (redacted at any length).
Returns:
Values to redact exactly: ``password``, ``secret``, ``token`` style
fields of at least 4 characters, plus the secret ``data`` value.
"""
found: list[str] = []
if secret_data and isinstance(body, Mapping):
data = body.get("data")
if isinstance(data, str) and data:
found.append(data)
_walk_sensitive(body, found, 0)
return found