fix: reject whitespace and control characters in base URLs and warn on plain http Gitea (SEC-10)

This commit is contained in:
Latte committed 2026-10-14 17:34:15 +02:00
1 parent 87f048cc4d
commit d96c3ac604
5 files changed
+284 -5

No files matched your search

+7
View File
@@ -109,6 +109,13 @@ def run_serve(args: argparse.Namespace) -> int:
if settings is None:
return 2
configure_logging(settings.log_level)
if settings.insecure_gitea_url():
print(
"aegis-gitea-mcp: warning: GITEA_URL uses plain http to a host that is not loopback: "
"the Gitea token, the OAuth client secret and every user's token travel unencrypted. "
"Use https",
file=sys.stderr,
)
if settings.mcp_transport == "http":
return transports.run_http(settings)
return transports.run_stdio(settings)
+16 -3
View File
@@ -15,7 +15,7 @@ import sys
from collections.abc import Mapping
from pathlib import Path
from typing import Annotated, Any, Literal
from urllib.parse import urlsplit
from urllib.parse import urlsplit, urlunsplit
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
@@ -147,8 +147,16 @@ def redirect_matches_allowlist(uri: str, allowlist: list[str]) -> bool:
def _normalize_http_url(value: str, *, field: str) -> str:
"""Validate an http(s) URL and return it without query, fragment or trailing slash."""
"""Validate an http(s) URL and return it rebuilt without query, fragment or trailing slash.
The result is assembled from the parsed parts, so the string that is validated
is the string that is used. ``urlsplit`` silently drops tabs and newlines
before parsing, so any whitespace, backslash or non-printable character
(control and bidi characters included) is rejected up front.
"""
candidate = value.strip()
if any(ch.isspace() or not ch.isprintable() or ch == "\\" for ch in candidate):
raise ValueError(f"{field} must not contain whitespace, backslashes or control characters")
parts = urlsplit(candidate)
if parts.scheme not in {"http", "https"}:
raise ValueError(f"{field} must start with http:// or https://")
@@ -162,7 +170,7 @@ def _normalize_http_url(value: str, *, field: str) -> str:
parts.port # noqa: B018 (accessing .port validates it)
except ValueError as exc:
raise ValueError(f"{field} has an invalid port") from exc
return candidate.rstrip("/")
return urlunsplit((parts.scheme, parts.netloc, parts.path.rstrip("/"), "", ""))
class Settings(BaseSettings):
@@ -403,6 +411,11 @@ class Settings(BaseSettings):
"""Audit log path, defaulting to ``DATA_DIR/audit.log``."""
return self.audit_log_path or (self.data_dir / "audit.log")
def insecure_gitea_url(self) -> bool:
"""True when ``GITEA_URL`` is plain http to a host that is not loopback."""
parts = urlsplit(self.gitea_url)
return parts.scheme == "http" and not is_loopback_host(parts.hostname or "")
def is_loopback_bind(self) -> bool:
"""Return True when ``MCP_HOST`` is a loopback address (127.0.0.0/8, ::1, localhost)."""
return is_loopback_host(self.mcp_host)