Dev #72
No files matched your search
@@ -9,11 +9,14 @@ Path safety rules
|
|||||||
Every value substituted into a path template (and every raw ``request()``
|
Every value substituted into a path template (and every raw ``request()``
|
||||||
path) is rejected when it is empty, contains a ``..`` or ``.`` path segment, an
|
path) is rejected when it is empty, contains a ``..`` or ``.`` path segment, an
|
||||||
empty segment, a backslash, ``?``, ``#``, ``%`` or a control character. After
|
empty segment, a backslash, ``?``, ``#``, ``%`` or a control character. After
|
||||||
that check the value is percent-encoded. Most parameters are quoted with
|
that check the value is percent-encoded. Parameters are quoted with
|
||||||
``safe=""`` so a ``/`` becomes ``%2F`` and can never open a new path segment.
|
``safe=""`` so a ``/`` becomes ``%2F`` and can never open a new path segment,
|
||||||
The parameters in :data:`SLASH_PATH_PARAMS` legitimately hold slashes (file
|
except the ``(path template, parameter)`` pairs in :data:`SLASH_ROUTES`.
|
||||||
paths, branch, tag and ref names) and are routed by Gitea through wildcard
|
Those are routed by Gitea 28.1.0 through wildcard routes (file paths, branch,
|
||||||
routes, so they are quoted with ``safe="/"``.
|
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
|
from __future__ import annotations
|
||||||
@@ -29,12 +32,32 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from aegis_gitea_mcp.catalog.model import Operation, Parameter
|
from aegis_gitea_mcp.catalog.model import Operation, Parameter
|
||||||
|
|
||||||
#: Path parameters that may contain ``/``. Gitea serves these routes with
|
#: ``(path template, parameter name)`` pairs that Gitea 28.1.0 serves through a
|
||||||
#: wildcard patterns (``contents/*``, ``branches/*``, ``compare/*``,
|
#: wildcard route, verified against a live instance: a raw ``/`` resolves and
|
||||||
#: ``git/refs/*``, ``tags/*``, ``pulls/{base}/*``), so refs such as
|
#: ``%2F`` would not. Everything else (including same-named parameters on
|
||||||
#: ``feature/login`` and file paths such as ``src/app/main.py`` are sent with
|
#: single-segment routes such as ``releases/tags/{tag}`` and
|
||||||
#: real slashes. Every other parameter is quoted with ``safe=""``.
|
#: ``editorconfig/{filepath}``) is quoted with ``safe=""``.
|
||||||
SLASH_PATH_PARAMS = frozenset({"filepath", "branch", "ref", "basehead", "head", "tag"})
|
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
|
#: Query parameter names that carry credentials or switch the acting user in
|
||||||
#: Gitea. They are never forwarded: the identity and the ``sudo`` argument are
|
#: Gitea. They are never forwarded: the identity and the ``sudo`` argument are
|
||||||
@@ -63,6 +86,12 @@ _SENSITIVE_BODY_KEYS = frozenset(
|
|||||||
"auth_password",
|
"auth_password",
|
||||||
"auth_token",
|
"auth_token",
|
||||||
"private_key",
|
"private_key",
|
||||||
|
"authorization_header",
|
||||||
|
"remote_password",
|
||||||
|
"mirror_password",
|
||||||
|
"mirror_token",
|
||||||
|
"aws_secret_access_key",
|
||||||
|
"aws_access_key_id",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_MIN_SENSITIVE_VALUE_CHARS = 4
|
_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)."""
|
"""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)
|
@dataclass(frozen=True)
|
||||||
class RequestBody:
|
class RequestBody:
|
||||||
"""Body of an outgoing request; at most one of the fields is set.
|
"""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")
|
raise ArgumentError(f"path placeholder '{name}' has no matching parameter")
|
||||||
value = _path_value(parameter, arguments)
|
value = _path_value(parameter, arguments)
|
||||||
check_segments(value, f"path parameter '{parameter.arg_name}'")
|
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
|
display[name] = value
|
||||||
return quote(value, safe=safe)
|
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)
|
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(
|
def _file_part(
|
||||||
parameter: Parameter, value: Any
|
parameter: Parameter, value: Any, max_file_bytes: int | None
|
||||||
) -> tuple[str, tuple[str | None, bytes, str | None]]:
|
) -> tuple[str, tuple[str | None, bytes, str | None]]:
|
||||||
label = f"file argument '{parameter.arg_name}'"
|
label = f"file argument '{parameter.arg_name}'"
|
||||||
if not isinstance(value, Mapping):
|
if not isinstance(value, Mapping):
|
||||||
@@ -263,6 +302,8 @@ def _file_part(
|
|||||||
filename = _single_line(filename, f"{label} filename", _MAX_FILENAME_CHARS)
|
filename = _single_line(filename, f"{label} filename", _MAX_FILENAME_CHARS)
|
||||||
if '"' in filename:
|
if '"' in filename:
|
||||||
raise ArgumentError(f"{label} filename must not contain a double quote")
|
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:
|
try:
|
||||||
content = base64.b64decode(encoded, validate=True)
|
content = base64.b64decode(encoded, validate=True)
|
||||||
except (binascii.Error, ValueError) as exc:
|
except (binascii.Error, ValueError) as exc:
|
||||||
@@ -275,14 +316,23 @@ def _file_part(
|
|||||||
return parameter.name, (filename, content, content_type or "application/octet-stream")
|
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.
|
"""Build the request body from the operation's body and formData parameters.
|
||||||
|
|
||||||
``formData`` parameters produce a multipart body (files are decoded from
|
``formData`` parameters produce a multipart body (files are decoded from
|
||||||
base64 strictly). A ``body`` parameter is sent as JSON, or as a plain string
|
base64 strictly). A ``body`` parameter is sent as JSON, or as a plain string
|
||||||
when the operation only consumes ``text/plain``.
|
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:
|
Raises:
|
||||||
|
PayloadTooLargeError: A file argument is larger than ``max_file_bytes``.
|
||||||
ArgumentError: A file argument or text body is malformed.
|
ArgumentError: A file argument or text body is malformed.
|
||||||
"""
|
"""
|
||||||
form_parts: list[tuple[str, tuple[str | None, bytes, str | None]]] = []
|
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:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
if parameter.is_file:
|
if parameter.is_file:
|
||||||
form_parts.append(_file_part(parameter, value))
|
form_parts.append(_file_part(parameter, value, max_file_bytes))
|
||||||
else:
|
else:
|
||||||
form_parts.append(
|
form_parts.append(
|
||||||
(parameter.name, (None, _scalar(value, parameter.arg_name).encode(), None))
|
(parameter.name, (None, _scalar(value, parameter.arg_name).encode(), None))
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from aegis_gitea_mcp.errors import kind_for_status, sanitize_message
|
|||||||
from aegis_gitea_mcp.gitea.encoding import (
|
from aegis_gitea_mcp.gitea.encoding import (
|
||||||
RAW_METHODS,
|
RAW_METHODS,
|
||||||
ArgumentError,
|
ArgumentError,
|
||||||
|
PayloadTooLargeError,
|
||||||
RequestBody,
|
RequestBody,
|
||||||
build_body,
|
build_body,
|
||||||
build_query,
|
build_query,
|
||||||
@@ -115,6 +116,10 @@ _MAX_TEXT_BYTES = 16 * 1024 * 1024
|
|||||||
#: Cap on the error body inspected for a message.
|
#: Cap on the error body inspected for a message.
|
||||||
_MAX_ERROR_BODY_BYTES = 64 * 1024
|
_MAX_ERROR_BODY_BYTES = 64 * 1024
|
||||||
_RAW_ACCEPT = "application/json, text/plain;q=0.9, */*;q=0.5"
|
_RAW_ACCEPT = "application/json, text/plain;q=0.9, */*;q=0.5"
|
||||||
|
#: Deepest JSON nesting accepted from Gitea. Deeper bodies are returned as text:
|
||||||
|
#: parsing and the recursive redaction/masking passes must never hit the
|
||||||
|
#: interpreter recursion limit (a hostile or broken upstream could try).
|
||||||
|
_MAX_JSON_DEPTH = 100
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -184,6 +189,34 @@ def _decode(body: bytes) -> str:
|
|||||||
return body.decode("utf-8", errors="replace")
|
return body.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _too_deep(value: Any, limit: int = _MAX_JSON_DEPTH) -> bool:
|
||||||
|
"""True when ``value`` nests lists/dicts deeper than ``limit`` (iterative walk)."""
|
||||||
|
stack: list[tuple[Any, int]] = [(value, 1)]
|
||||||
|
while stack:
|
||||||
|
item, depth = stack.pop()
|
||||||
|
if isinstance(item, dict):
|
||||||
|
children: Iterable[Any] = item.values()
|
||||||
|
elif isinstance(item, list):
|
||||||
|
children = item
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if depth > limit:
|
||||||
|
return True
|
||||||
|
stack.extend((child, depth + 1) for child in children)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(text: str) -> Any:
|
||||||
|
"""Parse JSON; raises ``ValueError`` for invalid, or too deeply nested, documents."""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
except RecursionError as exc:
|
||||||
|
raise ValueError("JSON nesting too deep") from exc
|
||||||
|
if _too_deep(parsed):
|
||||||
|
raise ValueError("JSON nesting too deep")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _declared_size(response: httpx.Response) -> int | None:
|
def _declared_size(response: httpx.Response) -> int | None:
|
||||||
"""Content-Length when it describes the decoded body (no content-encoding)."""
|
"""Content-Length when it describes the decoded body (no content-encoding)."""
|
||||||
if response.headers.get("content-encoding", "identity").lower() != "identity":
|
if response.headers.get("content-encoding", "identity").lower() != "identity":
|
||||||
@@ -213,7 +246,7 @@ def _error_message(body: bytes, status: int) -> str:
|
|||||||
text = _decode(body).strip()
|
text = _decode(body).strip()
|
||||||
message = text
|
message = text
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(text)
|
parsed = _parse_json(text)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
parsed = None
|
parsed = None
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
@@ -289,7 +322,7 @@ class GiteaExecutor:
|
|||||||
sudo = validate_sudo(arguments.get("sudo"))
|
sudo = validate_sudo(arguments.get("sudo"))
|
||||||
encoded_path, display_path = render_path(operation, arguments)
|
encoded_path, display_path = render_path(operation, arguments)
|
||||||
query = build_query(operation, arguments)
|
query = build_query(operation, arguments)
|
||||||
body = build_body(operation, arguments)
|
body = build_body(operation, arguments, self._settings.max_binary_bytes)
|
||||||
except ArgumentError as exc:
|
except ArgumentError as exc:
|
||||||
call = _Call(
|
call = _Call(
|
||||||
method=operation.method,
|
method=operation.method,
|
||||||
@@ -299,6 +332,8 @@ class GiteaExecutor:
|
|||||||
accept="",
|
accept="",
|
||||||
sudo=sudo,
|
sudo=sudo,
|
||||||
)
|
)
|
||||||
|
if isinstance(exc, PayloadTooLargeError):
|
||||||
|
return self._error(call, identity, None, "payload_too_large", str(exc))
|
||||||
return self._invalid(call, identity, str(exc))
|
return self._invalid(call, identity, str(exc))
|
||||||
|
|
||||||
body_param = next((p for p in operation.parameters if p.location == "body"), None)
|
body_param = next((p for p in operation.parameters if p.location == "body"), None)
|
||||||
@@ -409,6 +444,8 @@ class GiteaExecutor:
|
|||||||
return await self._handle(response, call, identity)
|
return await self._handle(response, call, identity)
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
return self._transport_error(exc, call, identity)
|
return self._transport_error(exc, call, identity)
|
||||||
|
except RecursionError:
|
||||||
|
return self._invalid(call, identity, "request body is nested too deeply")
|
||||||
finally:
|
finally:
|
||||||
if response is not None:
|
if response is not None:
|
||||||
await response.aclose()
|
await response.aclose()
|
||||||
@@ -509,7 +546,7 @@ class GiteaExecutor:
|
|||||||
def _decode_body(body: bytes, kind: str) -> Any:
|
def _decode_body(body: bytes, kind: str) -> Any:
|
||||||
if kind == "json":
|
if kind == "json":
|
||||||
try:
|
try:
|
||||||
return json.loads(body.decode("utf-8"))
|
return _parse_json(body.decode("utf-8"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
return _decode(body)
|
return _decode(body)
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""Hardening tests for the executor: slash routing, secret fields, nesting and upload caps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
|
||||||
|
from aegis_gitea_mcp.catalog import load_catalog
|
||||||
|
from aegis_gitea_mcp.gitea import GiteaExecutor
|
||||||
|
from aegis_gitea_mcp.gitea.encoding import SLASH_ROUTES, allows_slash, render_path
|
||||||
|
from tests.test_executor_helpers import (
|
||||||
|
UPLOAD_ATTACHMENT,
|
||||||
|
admin_identity,
|
||||||
|
make_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Wildcard routes of Gitea 28.1.0, verified against a live instance (raw slash
|
||||||
|
#: resolves). Written out independently of the implementation on purpose.
|
||||||
|
VERIFIED_WILDCARD = {
|
||||||
|
("/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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def executor() -> AsyncIterator[GiteaExecutor]:
|
||||||
|
instance = GiteaExecutor(make_settings())
|
||||||
|
yield instance
|
||||||
|
await instance.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_slash_decision_matches_verified_routes_for_every_catalog_parameter() -> None:
|
||||||
|
operations = load_catalog().operations
|
||||||
|
seen_routes: set[tuple[str, str]] = set()
|
||||||
|
for operation in operations:
|
||||||
|
path_params = [p for p in operation.parameters if p.location == "path"]
|
||||||
|
for target in path_params:
|
||||||
|
arguments = {p.arg_name: "x" for p in path_params}
|
||||||
|
arguments[target.arg_name] = "a/b"
|
||||||
|
encoded, display = render_path(operation, arguments)
|
||||||
|
key = (operation.path, target.name)
|
||||||
|
keeps_slash = "a/b" in encoded
|
||||||
|
assert keeps_slash == (key in VERIFIED_WILDCARD), (operation.operation_id, key)
|
||||||
|
assert ("a%2Fb" in encoded) == (not keeps_slash)
|
||||||
|
assert "a/b" in display
|
||||||
|
if keeps_slash:
|
||||||
|
seen_routes.add(key)
|
||||||
|
assert seen_routes == VERIFIED_WILDCARD
|
||||||
|
assert SLASH_ROUTES == VERIFIED_WILDCARD
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("template", "name"),
|
||||||
|
[
|
||||||
|
("/repos/{owner}/{repo}/releases/tags/{tag}", "tag"),
|
||||||
|
("/repos/{owner}/{repo}/editorconfig/{filepath}", "filepath"),
|
||||||
|
("/repos/{owner}/{repo}/archive/{archive}", "archive"),
|
||||||
|
("/repos/{owner}/{repo}/branch_protections/{name}", "name"),
|
||||||
|
("/repos/{owner}/{repo}/pulls/{base}/{head}", "base"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_same_named_or_single_segment_parameters_are_encoded(template: str, name: str) -> None:
|
||||||
|
assert not allows_slash(template, name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_releases_tag_and_editorconfig_send_percent_2f() -> None:
|
||||||
|
catalog = load_catalog()
|
||||||
|
by_path = {(op.method, op.path): op for op in catalog.operations}
|
||||||
|
release = by_path[("GET", "/repos/{owner}/{repo}/releases/tags/{tag}")]
|
||||||
|
editorconfig = by_path[("GET", "/repos/{owner}/{repo}/editorconfig/{filepath}")]
|
||||||
|
wildcard_tag = by_path[("GET", "/repos/{owner}/{repo}/tags/{tag}")]
|
||||||
|
|
||||||
|
base = {"owner": "o", "repo": "r"}
|
||||||
|
assert (
|
||||||
|
render_path(release, {**base, "tag": "rel/1.0"})[0] == "/repos/o/r/releases/tags/rel%2F1.0"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
render_path(editorconfig, {**base, "filepath": "sub/dir/a.py"})[0]
|
||||||
|
== "/repos/o/r/editorconfig/sub%2Fdir%2Fa.py"
|
||||||
|
)
|
||||||
|
assert render_path(wildcard_tag, {**base, "tag": "rel/1.0"})[0] == "/repos/o/r/tags/rel/1.0"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_tag_request_goes_out_with_encoded_slash(
|
||||||
|
executor: GiteaExecutor, httpx_mock: HTTPXMock
|
||||||
|
) -> None:
|
||||||
|
operation = next(
|
||||||
|
op
|
||||||
|
for op in load_catalog().operations
|
||||||
|
if op.path.endswith("/releases/tags/{tag}") and op.method == "GET"
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(json={"id": 1})
|
||||||
|
|
||||||
|
await executor.execute(
|
||||||
|
operation, {"owner": "o", "repo": "r", "tag": "rel/1.0"}, admin_identity()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert httpx_mock.get_requests()[0].url.raw_path == b"/api/v1/repos/o/r/releases/tags/rel%2F1.0"
|
||||||
|
|
||||||
|
|
||||||
|
# Secret-bearing request fields
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"field",
|
||||||
|
[
|
||||||
|
"authorization_header",
|
||||||
|
"remote_password",
|
||||||
|
"mirror_password",
|
||||||
|
"mirror_token",
|
||||||
|
"aws_secret_access_key",
|
||||||
|
"aws_access_key_id",
|
||||||
|
"auth_password",
|
||||||
|
"auth_token",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_secret_request_fields_are_not_echoed(
|
||||||
|
executor: GiteaExecutor, httpx_mock: HTTPXMock, field: str
|
||||||
|
) -> None:
|
||||||
|
value = "Bearer super-secret-value-123"
|
||||||
|
httpx_mock.add_response(status_code=201, json={"id": 1, field: value, "url": "https://x"})
|
||||||
|
operation = next(op for op in load_catalog().operations if op.operation_id == "adminCreateHook")
|
||||||
|
|
||||||
|
outcome = await executor.execute(
|
||||||
|
operation,
|
||||||
|
{"body": {"type": "gitea", "config": {"url": "https://x"}, field: value}},
|
||||||
|
admin_identity(),
|
||||||
|
)
|
||||||
|
|
||||||
|
dumped = json.dumps(outcome.payload)
|
||||||
|
assert value not in dumped
|
||||||
|
assert "[REDACTED]" in dumped
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hook_authorization_header_is_not_returned(
|
||||||
|
executor: GiteaExecutor, httpx_mock: HTTPXMock
|
||||||
|
) -> None:
|
||||||
|
header = "Basic dXNlcjpwYXNzd29yZA=="
|
||||||
|
httpx_mock.add_response(
|
||||||
|
status_code=201,
|
||||||
|
json={"id": 3, "type": "gitea", "authorization_header": header, "active": True},
|
||||||
|
)
|
||||||
|
operation = next(op for op in load_catalog().operations if op.operation_id == "orgCreateHook")
|
||||||
|
|
||||||
|
outcome = await executor.execute(
|
||||||
|
operation,
|
||||||
|
{
|
||||||
|
"org": "acme",
|
||||||
|
"body": {
|
||||||
|
"type": "gitea",
|
||||||
|
"config": {"url": "https://hooks.example.com", "content_type": "json"},
|
||||||
|
"authorization_header": header,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
admin_identity(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not outcome.is_error
|
||||||
|
assert header not in json.dumps(outcome.payload)
|
||||||
|
assert outcome.payload["data"]["authorization_header"] == "[REDACTED]"
|
||||||
|
assert outcome.payload["data"]["id"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# Deeply nested JSON
|
||||||
|
|
||||||
|
|
||||||
|
def _nested(depth: int) -> str:
|
||||||
|
return "[" * depth + "]" * depth
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [150, 100_000])
|
||||||
|
async def test_deeply_nested_json_success_body_is_returned_as_text(
|
||||||
|
executor: GiteaExecutor, httpx_mock: HTTPXMock, depth: int
|
||||||
|
) -> None:
|
||||||
|
operation = next(op for op in load_catalog().operations if op.operation_id == "adminGetHook")
|
||||||
|
httpx_mock.add_response(
|
||||||
|
content=_nested(depth).encode(), headers={"content-type": "application/json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = await executor.execute(operation, {"id": 1}, admin_identity())
|
||||||
|
|
||||||
|
assert not outcome.is_error
|
||||||
|
assert isinstance(outcome.payload["data"], str)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deeply_nested_json_error_body_still_gives_an_error(
|
||||||
|
executor: GiteaExecutor, httpx_mock: HTTPXMock
|
||||||
|
) -> None:
|
||||||
|
operation = next(op for op in load_catalog().operations if op.operation_id == "adminGetHook")
|
||||||
|
httpx_mock.add_response(
|
||||||
|
status_code=500,
|
||||||
|
content=_nested(100_000).encode(),
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = await executor.execute(operation, {"id": 1}, admin_identity())
|
||||||
|
|
||||||
|
assert outcome.is_error
|
||||||
|
assert outcome.payload["error"]["kind"] == "server_error"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_moderately_nested_json_still_parses(
|
||||||
|
executor: GiteaExecutor, httpx_mock: HTTPXMock
|
||||||
|
) -> None:
|
||||||
|
operation = next(op for op in load_catalog().operations if op.operation_id == "adminGetHook")
|
||||||
|
httpx_mock.add_response(
|
||||||
|
content=_nested(50).encode(), headers={"content-type": "application/json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = await executor.execute(operation, {"id": 1}, admin_identity())
|
||||||
|
|
||||||
|
assert isinstance(outcome.payload["data"], list)
|
||||||
|
|
||||||
|
|
||||||
|
# Upload size cap
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oversized_base64_upload_is_rejected_before_decoding(
|
||||||
|
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
executor = GiteaExecutor(make_settings(max_binary_bytes=10))
|
||||||
|
decoded: list[str] = []
|
||||||
|
import base64 as b64module
|
||||||
|
|
||||||
|
real = b64module.b64decode
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aegis_gitea_mcp.gitea.encoding.base64.b64decode",
|
||||||
|
lambda value, *a, **k: decoded.append(value) or real(value, *a, **k),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
outcome = await executor.execute(
|
||||||
|
UPLOAD_ATTACHMENT,
|
||||||
|
{
|
||||||
|
"owner": "o",
|
||||||
|
"repo": "r",
|
||||||
|
"id": 1,
|
||||||
|
"attachment": {
|
||||||
|
"filename": "big.bin",
|
||||||
|
"content_base64": base64.b64encode(b"x" * 11).decode(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
admin_identity(),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await executor.aclose()
|
||||||
|
|
||||||
|
assert outcome.is_error
|
||||||
|
assert outcome.payload["error"]["kind"] == "payload_too_large"
|
||||||
|
assert decoded == []
|
||||||
|
assert httpx_mock.get_requests() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_exactly_at_the_limit_is_sent(httpx_mock: HTTPXMock) -> None:
|
||||||
|
executor = GiteaExecutor(make_settings(max_binary_bytes=10))
|
||||||
|
httpx_mock.add_response(status_code=201, json={"id": 1})
|
||||||
|
try:
|
||||||
|
outcome = await executor.execute(
|
||||||
|
UPLOAD_ATTACHMENT,
|
||||||
|
{
|
||||||
|
"owner": "o",
|
||||||
|
"repo": "r",
|
||||||
|
"id": 1,
|
||||||
|
"attachment": {
|
||||||
|
"filename": "ok.bin",
|
||||||
|
"content_base64": base64.b64encode(b"x" * 10).decode(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
admin_identity(),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await executor.aclose()
|
||||||
|
|
||||||
|
assert not outcome.is_error
|
||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aegis_gitea_mcp.gitea.encoding import (
|
from aegis_gitea_mcp.gitea.encoding import (
|
||||||
SLASH_PATH_PARAMS,
|
SLASH_ROUTES,
|
||||||
ArgumentError,
|
ArgumentError,
|
||||||
check_segments,
|
check_segments,
|
||||||
render_path,
|
render_path,
|
||||||
@@ -20,8 +20,16 @@ from tests.test_executor_helpers import GET_BRANCH, GET_RAW_FILE, PULL_DIFF
|
|||||||
# Encoding
|
# Encoding
|
||||||
|
|
||||||
|
|
||||||
def test_slash_parameter_set_is_documented_and_minimal() -> None:
|
def test_slash_routes_are_the_verified_wildcard_routes() -> None:
|
||||||
assert {"filepath", "branch", "ref", "basehead", "head", "tag"} == SLASH_PATH_PARAMS
|
assert {name for _, name in SLASH_ROUTES} == {
|
||||||
|
"filepath",
|
||||||
|
"branch",
|
||||||
|
"ref",
|
||||||
|
"basehead",
|
||||||
|
"head",
|
||||||
|
"tag",
|
||||||
|
}
|
||||||
|
assert len(SLASH_ROUTES) == 11
|
||||||
|
|
||||||
|
|
||||||
def test_render_path_returns_encoded_and_display_forms() -> None:
|
def test_render_path_returns_encoded_and_display_forms() -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user