From 0f5f5cdd959ae352699517e44e743d6fe8e4c590 Mon Sep 17 00:00:00 2001 From: latte Date: Wed, 14 Oct 2026 17:52:07 +0200 Subject: [PATCH] fix: do not echo setting values in configuration errors; test: stdio and config redaction --- src/aegis_gitea_mcp/config.py | 11 ++--- tests/security/test_redaction.py | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/aegis_gitea_mcp/config.py b/src/aegis_gitea_mcp/config.py index 1827625..283b522 100644 --- a/src/aegis_gitea_mcp/config.py +++ b/src/aegis_gitea_mcp/config.py @@ -336,10 +336,11 @@ class Settings(BaseSettings): """Accept only granular Gitea scopes (unknown scopes make Gitea grant full access).""" unknown = sorted({scope for scope in value if scope not in GITEA_SCOPES}) if unknown: + # The offending values are not echoed: a misplaced secret must not reach stderr. raise ValueError( - f"OAUTH_SCOPES contains unknown scope(s): {', '.join(unknown)}. " - "Gitea grants full access for unknown scopes, so only granular " - "read:/write: scopes are accepted" + f"OAUTH_SCOPES contains {len(unknown)} unknown scope(s). Gitea grants full " + "access for unknown scopes, so only granular read:/write: scopes are accepted " + f"({', '.join(sorted(GITEA_SCOPES))})" ) if not value: raise ValueError("OAUTH_SCOPES must not be empty") @@ -365,9 +366,7 @@ class Settings(BaseSettings): unknown = sorted({item for item in lowered if item != "all" and item not in TOOL_DOMAINS}) if unknown: allowed = ", ".join(sorted(TOOL_DOMAINS | {"all"})) - raise ValueError( - f"TOOLSETS contains unknown domain(s): {', '.join(unknown)}; use {allowed}" - ) + raise ValueError(f"TOOLSETS contains {len(unknown)} unknown domain(s); use {allowed}") if not lowered: raise ValueError("TOOLSETS must not be empty (use 'all')") return list(dict.fromkeys(lowered)) diff --git a/tests/security/test_redaction.py b/tests/security/test_redaction.py index eedf5e8..2b55570 100644 --- a/tests/security/test_redaction.py +++ b/tests/security/test_redaction.py @@ -304,3 +304,85 @@ def test_secrets_in_object_keys_are_masked_like_values() -> None: assert token not in json.dumps(masked) and token not in json.dumps(blocked) assert masked["outer"][0]["plain"] != token assert blocked != nested + + +# Configuration errors and a real stdio process + + +@pytest.mark.parametrize( + ("variable", "value"), + [ + ("GITEA_URL", "https://user:" + GITEA_TOKEN + "@gitea.example.com"), + ("GITEA_URL", "https://gitea.example.com?token=" + GITEA_TOKEN), + ("MCP_BEARER_TOKEN", "short-" + "x" * 5), + ("PUBLIC_BASE_URL", "http://mcp.example.com/" + GITEA_TOKEN), + ("OAUTH_SCOPES", GITEA_TOKEN), + ("TOKEN_ENCRYPTION_KEY", "tooshort-" + ENCRYPTION_KEY[:5]), + ("MCP_PORT", GITEA_TOKEN), + ("MCP_PATH", GITEA_TOKEN), + ("FORWARDED_ALLOW_IPS", GITEA_TOKEN), + ("LOG_LEVEL", GITEA_TOKEN), + ], +) +def test_configuration_errors_name_the_setting_but_never_echo_its_value( + variable: str, + value: str, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from aegis_gitea_mcp import cli + from aegis_gitea_mcp.cli import main + + monkeypatch.setenv("GITEA_URL", "https://gitea.example.com") + monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN) + monkeypatch.setenv(variable, value) + monkeypatch.setattr(cli, "configure_logging", lambda level: None) + + assert main(["serve", "--transport", "http"]) == 2 + + err = capsys.readouterr().err + assert variable in err + assert value not in err and GITEA_TOKEN not in err + assert "Traceback" not in err + + +async def test_a_real_stdio_process_keeps_every_secret_out_of_stdout_stderr_and_the_audit_file( + tmp_path: Any, +) -> None: + import sys + + from mcp import Client + from mcp.client.stdio import StdioServerParameters, stdio_client + + secrets = [GITEA_TOKEN, BEARER_TOKEN, APP_CLIENT_SECRET, ENCRYPTION_KEY] + env = { + "GITEA_URL": "http://127.0.0.1:9", # nothing listens: every call fails + "GITEA_TOKEN": GITEA_TOKEN, + "MCP_BEARER_TOKEN": BEARER_TOKEN, + "GITEA_OAUTH_CLIENT_SECRET": APP_CLIENT_SECRET, + "TOKEN_ENCRYPTION_KEY": ENCRYPTION_KEY, + "DATA_DIR": str(tmp_path / "state"), + "AUDIT_LOG_PATH": str(tmp_path / "state" / "audit.log"), + "REQUEST_TIMEOUT_SECONDS": "2", + "LOG_LEVEL": "DEBUG", + } + params = StdioServerParameters( + command=sys.executable, + args=["-m", "aegis_gitea_mcp.cli", "serve", "--transport", "stdio"], + env=env, + cwd=str(tmp_path), + ) + stderr_path = tmp_path / "stderr.log" + with stderr_path.open("w", encoding="utf-8") as errlog: + async with Client(stdio_client(params, errlog=errlog)) as client: + results = [ + await client.call_tool("gitea_repo_get", {"owner": "o", "repo": "r"}), + await client.call_tool("gitea_repo_get", {"owner": 5}), + await client.call_tool("gitea_api_request", {"method": "GET", "path": "/user"}), + ] + + for result in results: + assert result.is_error + assert_clean(result.content[0].text, secrets) # type: ignore[union-attr] + assert_clean(stderr_path.read_text(encoding="utf-8"), secrets) + assert_clean((tmp_path / "state" / "audit.log").read_text(encoding="utf-8"), secrets)