fix: load ./.env only when private and owned, add --env-file and AEGIS_ENV_FILE (SEC-04)
This commit is contained in:
1 parent
7307214ab6
commit
79aa4f7fc0
11 files changed
+360
-20
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user