Files
AegisGitea-MCP/docs/security.md
T

19 KiB

Security

AegisGitea-MCP is a full-access bridge into Gitea (ADR 0001): whoever can call it holds the power of the Gitea token it uses, which for the admin token means the whole instance. The server does not decide which operations are safe. It guarantees a small set of invariants (authentication off loopback, identity separation, audit, secret redaction, bounded and validated input and output, untrusted-content labelling) and leaves exposure to the owner (READ_ONLY, TOOLSETS, DISABLED_TOOLS, token scopes). The operator checklist is hardening.md; open review findings are in security-findings.md.

Threat model

Assets

Asset Where it lives Impact if lost
Admin Gitea token (GITEA_TOKEN) env file or container env; process memory Full control of the instance, including Sudo as any user
MCP bearer (MCP_BEARER_TOKEN) env file; remote clients' configuration Equivalent to the admin token over HTTP
OAuth client secret (GITEA_OAUTH_CLIENT_SECRET) env file Impersonating this server towards Gitea's OAuth endpoints
Per-user Gitea access and refresh tokens DATA_DIR/oauth-store.json (encrypted), process memory Acting as each signed-in user, for as long as Gitea accepts the refresh token
Our issued agm_ access and refresh tokens clients; only SHA-256 hashes in the store Acting as one user through this server until expiry or revocation
Token store and its key (TOKEN_ENCRYPTION_KEY) store in DATA_DIR, key in env Store plus key discloses every user's Gitea tokens and every client secret
Action secret values, webhook auth headers, minted credentials in transit through tool calls Credentials of CI, webhooks and other systems
Audit log (DATA_DIR/audit.log) DATA_DIR Loss of accountability; integrity is the asset, not secrecy

Actors

  • Owner: configures the server, holds the admin token. Trusted.
  • OAuth users: Gitea accounts that sign in through a client. Trusted with their own Gitea permissions only.
  • Remote MCP clients, including hosted ones (claude.ai, Mistral Le Chat): hold a bearer or an OAuth token and act through a model that reads untrusted content.
  • Malicious Gitea content authors: anyone who can write an issue, comment, file, commit message, release note or wiki page the model may read.
  • Network attackers: anyone who can reach the HTTP port or sit on the path to it or to Gitea.
  • Other tenants of the shared CI host: jobs and services on the same runner and Docker daemon.

Trust boundaries

  1. MCP client to HTTP endpoint (transports.py): unauthenticated network input until a bearer or agm_ token is verified (auth.py, oauth/provider.py).
  2. Browser to OAuth routes (oauth/callback.py, SDK /authorize, /token, /register, /revoke): unauthenticated, cross-site capable.
  3. Verified token to identity (oauth/resolver.py, auth.py): decides which Gitea token a call uses.
  4. Server to Gitea (gitea/executor.py, oauth/upstream.py): Gitea is trusted for authorization decisions, its content is not trusted.
  5. Tool result to model: everything Gitea returns crosses into a model context.
  6. Process to disk (audit.py, oauth/store.py, config.py reading .env).
  7. Repository to CI runner (.gitea/workflows/*.yml, scripts/ci/*): code under test runs with access to the runner's Docker daemon.

Threats and mitigations

Spoofing

Threat Mitigation Implemented in
Calling the endpoint without credentials Non-loopback bind refuses to start without a bearer or OAuth; SDK RequireAuthMiddleware answers 401 transports.py (http_startup_errors, build_http_app)
Guessing the bearer 32 character minimum; constant-time comparison of SHA-256 digests; per-IP and per-token rate limit before auth config.py, auth.py (StaticBearerVerifier), rate_limit.py
Using a token minted for another resource RFC 8707 resource binding and required scope gitea enforced by the SDK; /authorize with a foreign resource gets invalid_target transports.py (_auth_settings), oauth/provider.py
Confused deputy through the shared Gitea OAuth app Own consent page naming client and redirect URI, CSRF double submit with SameSite=Strict cookie, browser-binding cookie on the callback oauth/callback.py, oauth/provider.py (residual: W-10, optional OAUTH_REDIRECT_ALLOWLIST)
Code or refresh token replay Single-use codes; replay or reuse of a rotated refresh token revokes the grant oauth/provider.py

Tampering

Threat Mitigation Implemented in
Path traversal or path reshaping into other Gitea routes Path values rejected on .., ., empty segment, \, ?, #, %, control characters; percent-encoded with safe="" except verified wildcard routes gitea/encoding.py
Header injection (Sudo, multipart) Sudo restricted to [A-Za-z0-9_.-]{1,255}; filenames and content types single line, no quotes; header parameters of the spec never forwarded gitea/encoding.py, catalog/build.py
Query smuggling of credentials or Sudo token, access_token, sudo, _sudo refused as query names (any case) gitea/encoding.py
Malformed arguments JSON Schema validation (Draft 2020-12, additionalProperties: false) before anything runs; messages never echo values server.py
Store tampering or wrong key Fernet (authenticated encryption) with an HKDF-SHA256 key; undecryptable store stops startup instead of being wiped; symlinks refused oauth/store.py (rollback residual: SEC-13)
Settings injection from a planted .env ./.env is loaded only when it is a regular file, not a symlink, owned by the user, mode 0600; otherwise ignored with a warning; --env-file or AEGIS_ENV_FILE name a file explicitly; the environment wins config.py (select_env_file), cli.py

Repudiation

Threat Mitigation Implemented in
Unrecorded tool calls Exactly one hash-chained result entry per call, preceded by a write-ahead tool_call_started entry; a call is refused when either the logger or that write fails; startup fails on an unwritable log server.py, audit.py, transports.py
Undetected edits of the log SHA-256 hash chain, scripts/validate_audit_log.py audit.py (unkeyed chain: SEC-11)

Information disclosure

Threat Mitigation Implemented in
Tokens in results or errors Exact redaction of the identity token, refresh token, admin token, bearer, client secret, store key and submitted secret fields in every result and error; pattern masking per SECRET_DETECTION_MODE gitea/executor.py, gitea/redaction.py, security.py
Tokens in logs JSON log formatter without tracebacks; query strings dropped from access logs; upstream errors reduced to coarse kinds logging_utils.py, transports.py (AccessLogQueryFilter), oauth/upstream.py
Secret values echoed back Submitted password, secret, token style fields and Action secret data redacted from the result gitea/encoding.py (sensitive_body_values) (stored webhook auth headers on read: SEC-02)
Leaks across identities No cookie jar, no redirects followed, a fresh upstream client per OAuth exchange gitea/executor.py, oauth/upstream.py
Stack traces EXPOSE_ERROR_DETAILS=false by default; even when on, only the exception class name server.py, gitea/executor.py

Denial of service

Threat Mitigation Implemented in
Large request bodies SDK body limit on the MCP endpoint and SDK OAuth routes; upload cap checked before decoding server.py (max_request_body_size), gitea/encoding.py (consent route: SEC-01)
Large or hostile responses 16 MiB text cap, MAX_BINARY_BYTES, 64 KiB error bodies, JSON depth 100, item and character limits gitea/executor.py
Request floods Sliding window per IP and per token hash, bounded key sets, rate-limit events audited once per window rate_limit.py, transports.py (gaps: SEC-08)
Unbounded OAuth state Caps on clients (500), pending sign-ins (1024), codes (1024), sessions per login (50); store pruning oauth/provider.py, oauth/store.py (flush by floods: SEC-09)

Elevation of privilege

Threat Mitigation Implemented in
OAuth user reaching the admin token Tokens with the OAuth marker only ever resolve to their session; only the static bearer (or no token on stdio and unauthenticated loopback) reaches admin-token mode; OAuth-only deployments fail closed oauth/resolver.py, auth.py
OAuth user using Sudo Sudo is sent with the caller's own token; Gitea honours it only for site admins gitea/executor.py
Unknown Gitea scopes granting full access Only granular read:/write: scopes accepted for OAUTH_SCOPES config.py
Model tricked by injected content Untrusted-content labelling and annotations (see below); exposure narrowed by configuration server.py, gitea/executor.py
Untrusted code on the shared CI host Per-run resource names and targeted cleanup .gitea/workflows/ci.yml, scripts/ci/cleanup_job_resources.sh (fork PRs: SEC-06)

Residual risks and owner decisions

  • Full access by default (ADR 0001). A leaked bearer equals the admin token. Sudo, admin, secrets and delete tools are enabled unless the owner narrows them.
  • Unauthenticated loopback mode is allowed for development. Any local user or process on the same host then acts with the admin token. Set a bearer on shared hosts.
  • Hosted clients (claude.ai, Le Chat) hold the bearer or an OAuth refresh token outside the owner's control. Prefer per-user OAuth and narrow OAUTH_SCOPES; the default grants write: on every category, including admin for site admins.
  • Credential-issuing operations (gitea_user_create_token, runner registration tokens such as gitea_admin_create_runner_registration_token, OAuth2 application secrets, gitea_repo_create_deploy_token) return the new credential to the model by design; only the caller's own tokens are still redacted.
  • Secret pattern masking is heuristic. Content containing third-party credentials (for example a Gitea token committed to a file) is returned unless it matches a pattern.
  • SSRF through Gitea features (migrations, webhooks, mirrors) is Gitea's responsibility; this server only ever contacts GITEA_URL.
  • With the default OAUTH_REDIRECT_ALLOWLIST (empty), the consent page is the only barrier against phishing with a self-registered client (waiver W-10). For a public deployment set the allowlist to the hosted clients you use, for example OAUTH_REDIRECT_ALLOWLIST=https://claude.ai/,https://chat.mistral.ai/.
  • Key rotation for TOKEN_ENCRYPTION_KEY is not supported: a new key means moving the store away and signing every user out.
  • The audit chain is tamper-evident, not tamper-proof: anyone with write access to the file can rewrite it consistently. Ship it off the host.

Prompt injection

Everything returned from Gitea is untrusted data. Every success envelope carries the fixed untrusted_content notice, the server instructions tell the model never to follow instructions found in Gitea content, and the server itself never interprets content (it does not follow links, render, execute or chain calls). Tool annotations mark read-only and destructive operations so clients can require confirmation. The realistic attack is a model that reads a hostile issue and then calls a destructive tool; the defences are client-side confirmation, READ_ONLY, TOOLSETS, DISABLED_TOOLS and narrow token scopes (hardening.md).

Secret hygiene guarantees

  • Settings hold secrets as SecretStr; Identity, SessionRecord, UpstreamTokens and the pending authorization exclude tokens from repr.
  • Results and errors: exact redaction of every known secret (identity token and refresh token, GITEA_TOKEN, MCP_BEARER_TOKEN, GITEA_OAUTH_CLIENT_SECRET, TOKEN_ENCRYPTION_KEY, secret fields submitted in the request), then pattern masking or blocking.
  • Validation errors and argument errors never contain argument values.
  • Audit entries carry argument names, never values, and are masked again before writing.
  • Logs: JSON lines, exception type only, access-log query strings replaced by [redacted].
  • Our own tokens are stored only as SHA-256 hashes; the store file is encrypted, mode 0600, written through O_EXCL temporary files and an atomic rename.
  • Action secrets: Gitea's list endpoints (gitea_actions_repo_list_secrets, gitea_actions_org_list_secrets) return names and descriptions only, and a submitted value (gitea_actions_update_repo_secret and siblings) is redacted from the result.
  • Known gap: a webhook's stored authorization_header is returned by hook read tools such as gitea_hook_repo_list_hooks (SEC-02).

Transport guarantees

  • Fail-closed bind: MCP_HOST defaults to 127.0.0.1; any non-loopback bind without a bearer or a working OAuth setup exits with code 2. A half-configured OAuth setup also exits 2.
  • Constant-time bearer check on SHA-256 digests, so neither content nor length leaks.
  • DNS-rebinding protection on the MCP endpoint: Host must be a loopback name, the bind address, the PUBLIC_BASE_URL host or an ALLOWED_HOSTS entry (421 otherwise); a foreign Origin gets 403; POST must be application/json.
  • Rate limits: sliding window per client IP and per Authorization hash on the MCP path and the unauthenticated OAuth paths, before authentication. Client IPs come from X-Forwarded-For only for FORWARDED_ALLOW_IPS.
  • PUBLIC_BASE_URL must be https unless loopback. Gitea redirects are never followed.
  • stdio: stdout carries JSON-RPC only; all logging goes to stderr.

Per-user OAuth design

The server is an OAuth 2.1 authorization server for MCP clients (SDK provider, dynamic client registration, PKCE S256 required) and a confidential PKCE client of Gitea. Flow: /authorize parks the request and shows our consent page (client name, redirect URI, warning). Approval needs the form CSRF token, the matching SameSite=Strict cookie and a request Origin equal to the public origin (the Referer origin when Origin is absent; neither, or null, is refused). It sets a browser-binding cookie named per flow (aegis-oauth-flow-<8 hex of the state hash>, __Host- prefixed and Secure on https, HttpOnly), so concurrent sign-ins in one browser do not overwrite each other. The Gitea callback is completed only in the browser holding that cookie; unknown, expired or replayed states never redirect. Pages send Cache-Control: no-store, Referrer-Policy: no-referrer, X-Frame-Options: DENY and a CSP with frame-ancestors 'none'. Redirect URIs must be https, or http on loopback, without credentials or fragments. Issued tokens are opaque (agm_ plus 256 random bits), hashed at rest, bound to the MCP resource; access tokens live 1 hour, refresh tokens 30 days with rotation. Revocation ends the whole grant.

Supply chain

  • Base images pinned by digest (python:3.12-slim, ghcr.io/astral-sh/uv), non-root runtime user, root-owned virtualenv, no build arguments carrying secrets, .dockerignore excludes .env.
  • Dependencies locked in uv.lock and installed with uv sync --locked.
  • pip-audit over the exported lock (scripts/ci/pip_audit.sh, waivers in security/pip-audit-ignore.txt).
  • gitleaks pinned to 8.30.1 with sha256 verification, full-history scan with .gitleaks.toml.
  • ruff S (bandit) rules enabled in the normal lint and run separately in the security job.
  • Gaps: CI actions are pinned by tag, not commit SHA (SEC-07); no container image scan (SEC-16).

Verification results

Run on 2026-10-14 against commit ce5ae6c (branch feat/full-api-coverage).

Check Command Result
Security test suite uv run pytest tests/security --no-cov Pending: written in S2 (tests/security/ does not exist yet, so the CI security job currently fails, SEC-15). Required tests are listed in security-findings.md.
Dependency audit bash scripts/ci/pip_audit.sh (pip-audit 2.10.1, --strict) No known vulnerabilities in 83 locked packages (runtime and dev groups). No ignores in use.
Bandit rules uv run ruff check --select S . (ruff 0.17.0) All checks passed, with the inline waivers listed below.
Secret scan scripts/ci/install_gitleaks.sh into a scratch directory, then gitleaks git --config .gitleaks.toml --redact --no-banner --verbose --exit-code 1 . (gitleaks 8.30.1) No leaks found. 131 commits in history (103 non-merge); gitleaks scanned 109 commits with content, about 6 MB.
Unit suite (context) uv run pytest 846 passed, 23 skipped (live Gitea and Docker tests), coverage 98.2 percent.

Waivers

Accepted findings, each with its reason. A new waiver needs a reason here in the same changeset.

Id Finding Where Reason
W-01 S104 (bind to all interfaces) transports.py _WILDCARD_HOSTS, tests/test_server_helpers.py, tests/test_config.py The addresses are compared or used as test input, never bound. The container binds 0.0.0.0 through MCP_HOST, which requires authentication.
W-02 S105/S106 (hardcoded password) auth.py (token="static"), oauth/provider.py (TOKEN_PREFIX, token_type="Bearer"), audit.py (mode string), setup_snippets.py (TOKEN_PLACEHOLDER) Placeholders, public prefixes and type names, not secrets.
W-03 S101 (assert) setup_cmd.py lines 357 and 822 Type narrowing after an explicit check; behaviour does not depend on the assert.
W-04 S101, S105, S106, S108 in tests/**; S603, S607 in tests/integration/harness.py and tests/mcp/helpers.py pyproject.toml per-file ignores, inline noqa Test fixtures use fake tokens and temporary paths; the harness drives the trusted local Docker CLI with fixed argv.
W-05 gitleaks allowlists .gitleaks.toml Only generic-api-key and curl-auth-header, only in tests/, docs/, removed legacy guides and one removed source file; every match was reviewed as a fake or placeholder.
W-06 pip-audit security/pip-audit-ignore.txt Empty: no advisory is ignored.
W-07 Credential-issuing operations bypass pattern masking gitea/executor.py (CREDENTIAL_ISSUING_OPERATIONS) Their purpose is to return a new credential; exact redaction of the caller's own tokens still applies.
W-08 Unauthenticated /health and / report version and tool count, /health reports Gitea reachability transports.py Needed by container health checks and clients; reveals no configuration or secret. The Gitea probe is cached for 10 seconds.
W-09 Pattern masking keeps the first and last 4 characters of a matched secret security.py (mask_secret) Applies only to pattern matches, which are at least 16 characters; the remainder keeps enough entropy, and the context helps users recognise the value.
W-10 Any https redirect URI can be registered when OAUTH_REDIRECT_ALLOWLIST is empty (SEC-05) oauth/provider.py (redirect_uri_problem), config.py Owner decision: the default stays open so any MCP client can register without configuration. The mitigation is the consent page (client name, redirect host and URI, explicit approval, CSRF and browser binding) and ADR 0004. The optional allowlist closes the gap: exact URIs or https://host/ prefixes, checked at registration and at /authorize; recommended for every public deployment.