fix: load ./.env only when private and owned, add --env-file and AEGIS_ENV_FILE (SEC-04)

This commit is contained in:
Latte committed 2026-10-14 17:22:29 +02:00
1 parent 7307214ab6
commit 79aa4f7fc0
11 files changed
+360 -20

No files matched your search

+21 -1
View File
@@ -13,7 +13,14 @@ from collections.abc import Sequence
from pydantic import ValidationError
from aegis_gitea_mcp import __version__, setup_cmd
from aegis_gitea_mcp.config import Settings, get_settings, reset_settings
from aegis_gitea_mcp.config import (
ENV_FILE_VARIABLE,
EnvFileError,
Settings,
get_settings,
reset_settings,
set_env_file,
)
from aegis_gitea_mcp.logging_utils import configure_logging
@@ -51,6 +58,15 @@ def build_parser() -> argparse.ArgumentParser:
metavar="PORT",
help="HTTP port (default: MCP_PORT)",
)
serve.add_argument(
"--env-file",
default=None,
metavar="PATH",
help=(
f"env file to load (default: {ENV_FILE_VARIABLE}, else ./.env when it is a regular "
"file owned by you with mode 0600; real environment variables always win)"
),
)
setup = subparsers.add_parser("setup", help="verify a Gitea instance and write the env file")
setup_cmd.add_arguments(setup)
@@ -69,9 +85,13 @@ def _load_settings(args: argparse.Namespace) -> Settings | None:
for name, value in overrides.items():
if value is not None:
os.environ[name] = value
set_env_file(getattr(args, "env_file", None))
reset_settings()
try:
return get_settings()
except EnvFileError as exc:
print(f"aegis-gitea-mcp: {exc}", file=sys.stderr)
return None
except ValidationError as exc:
# Print field names and messages only: str(exc) would echo the offending values.
print("aegis-gitea-mcp: invalid configuration:", file=sys.stderr)
+101 -6
View File
@@ -1,14 +1,18 @@
"""Configuration management for AegisGitea MCP.
All settings come from environment variables (case-insensitive) or a ``.env``
file in the working directory. Secrets are ``SecretStr`` so they never appear in
``repr()`` or model dumps. Requirements that depend on how the server is started
All settings come from environment variables (case-insensitive) or an env file.
The env file is never trusted implicitly: see :func:`select_env_file`. Secrets
are ``SecretStr`` so they never appear in ``repr()`` or model dumps. Requirements that depend on how the server is started
(for example ``GITEA_TOKEN`` for stdio) are checked at serve time, not on import.
"""
from __future__ import annotations
import ipaddress
import os
import stat
import sys
from collections.abc import Mapping
from pathlib import Path
from typing import Annotated, Any, Literal
from urllib.parse import urlsplit
@@ -107,7 +111,7 @@ class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
# No implicit env_file: get_settings() decides which file to load (SEC-04).
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
@@ -347,14 +351,105 @@ class Settings(BaseSettings):
return self.oauth_enabled and not self.oauth_config_errors()
#: Environment variable naming an env file (same as ``--env-file``).
ENV_FILE_VARIABLE = "AEGIS_ENV_FILE"
DEFAULT_ENV_FILE = ".env"
class EnvFileError(ValueError):
"""An explicitly requested env file cannot be used (the message is safe to print)."""
def env_file_problem(path: Path) -> str | None:
"""Return why the implicit env file ``path`` is not trusted, or None when it may be loaded.
Trusted means: a regular file (not a symlink) owned by the current user that
group and others cannot access (mode 0600, which ``setup`` writes). A missing
file is not a problem here.
"""
try:
info = os.lstat(path)
except FileNotFoundError:
return None
except OSError as exc:
return f"it cannot be inspected ({exc.strerror or type(exc).__name__})"
if stat.S_ISLNK(info.st_mode):
return "it is a symlink"
if not stat.S_ISREG(info.st_mode):
return "it is not a regular file"
if info.st_uid != getattr(os, "geteuid", lambda: info.st_uid)():
return "it is owned by another user"
mode = stat.S_IMODE(info.st_mode)
if mode & 0o077:
return f"its permissions are {mode:04o} (group or others have access)"
return None
def select_env_file(
explicit: str | Path | None = None,
*,
environ: Mapping[str, str] | None = None,
directory: Path | None = None,
) -> tuple[Path | None, str | None]:
"""Choose the env file to load; returns ``(path, warning)``.
Order: ``explicit`` (``--env-file``), then ``AEGIS_ENV_FILE``, then ``./.env``.
A file named explicitly is the operator's choice and is used as is. The
implicit ``./.env`` is loaded only when :func:`env_file_problem` finds nothing
wrong; otherwise it is ignored and ``warning`` says why and how to fix it. A
working directory is not trustworthy by itself: clients start stdio servers
inside the project (possibly a cloned repository) they are working on, and a
planted ``.env`` could switch off redaction or audit, or redirect the token.
Real environment variables always take precedence over the file.
Raises:
EnvFileError: An explicitly named file is missing or not a regular file.
"""
env = os.environ if environ is None else environ
named = explicit or env.get(ENV_FILE_VARIABLE, "").strip() or None
if named is not None:
path = Path(named).expanduser()
try:
regular = stat.S_ISREG(os.stat(path).st_mode)
except OSError:
regular = False
if not regular:
raise EnvFileError(f"env file {path} does not exist or is not a regular file")
return path, None
path = (directory or Path.cwd()) / DEFAULT_ENV_FILE
problem = env_file_problem(path)
if problem is not None:
return None, (
f"ignoring ./{DEFAULT_ENV_FILE}: {problem}. Run 'chmod 600 {DEFAULT_ENV_FILE}' "
f"(and make sure you own it), or pass --env-file PATH (or set {ENV_FILE_VARIABLE}) "
"to load a file explicitly"
)
return (path if path.exists() else None), None
_settings: Settings | None = None
_env_file_choice: str | Path | None = None
def set_env_file(path: str | Path | None) -> None:
"""Choose the env file for the next :func:`get_settings` call (``--env-file``)."""
global _env_file_choice
_env_file_choice = path
reset_settings()
def get_settings() -> Settings:
"""Get or create the global settings instance."""
"""Get or create the global settings instance.
Raises:
EnvFileError: An explicitly named env file cannot be used.
"""
global _settings
if _settings is None:
_settings = Settings() # type: ignore[call-arg]
path, warning = select_env_file(_env_file_choice)
if warning:
print(f"aegis-gitea-mcp: warning: {warning}", file=sys.stderr)
_settings = Settings(_env_file=path) # type: ignore[call-arg]
return _settings
+5 -2
View File
@@ -744,7 +744,10 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
help="public https URL of this MCP server (enables the OAuth app)",
)
parser.add_argument(
"--env-file", default=".env", help="env file to create or update (default: ./.env)"
"--env-file",
default=None,
metavar="PATH",
help="env file to create or update (default: AEGIS_ENV_FILE, else ./.env)",
)
parser.add_argument(
"--template",
@@ -798,7 +801,7 @@ def _generated(existing: dict[str, EnvEntry], key: str, values: dict[str, str])
def _run(args: argparse.Namespace) -> int:
env_file = Path(args.env_file)
env_file = Path(args.env_file or os.environ.get("AEGIS_ENV_FILE", "").strip() or ".env")
check_env_target(env_file)
template = _load_template(args.template)
existing, old_mode = _read_existing(env_file)