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
- MCP client to HTTP endpoint (
transports.py): unauthenticated network input until a bearer oragm_token is verified (auth.py,oauth/provider.py). - Browser to OAuth routes (
oauth/callback.py, SDK/authorize,/token,/register,/revoke): unauthenticated, cross-site capable. - Verified token to identity (
oauth/resolver.py,auth.py): decides which Gitea token a call uses. - Server to Gitea (
gitea/executor.py,oauth/upstream.py): Gitea is trusted for authorization decisions, its content is not trusted. - Tool result to model: everything Gitea returns crosses into a model context.
- Process to disk (
audit.py,oauth/store.py,config.pyreading.env). - 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 grantswrite:on every category, including admin for site admins. - Credential-issuing operations (
gitea_user_create_token, runner registration tokens such asgitea_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 exampleOAUTH_REDIRECT_ALLOWLIST=https://claude.ai/,https://chat.mistral.ai/. - Key rotation for
TOKEN_ENCRYPTION_KEYis 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,UpstreamTokensand the pending authorization exclude tokens fromrepr. - 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_EXCLtemporary 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_secretand siblings) is redacted from the result. - Known gap: a webhook's stored
authorization_headeris returned by hook read tools such asgitea_hook_repo_list_hooks(SEC-02).
Transport guarantees
- Fail-closed bind:
MCP_HOSTdefaults to127.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:
Hostmust be a loopback name, the bind address, thePUBLIC_BASE_URLhost or anALLOWED_HOSTSentry (421 otherwise); a foreignOrigingets 403; POST must beapplication/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-Foronly forFORWARDED_ALLOW_IPS. PUBLIC_BASE_URLmust 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 and the matching SameSite=Strict cookie and sets a
browser-binding cookie (__Host- prefixed and Secure on https, HttpOnly). 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,.dockerignoreexcludes.env. - Dependencies locked in
uv.lockand installed withuv sync --locked. pip-auditover the exported lock (scripts/ci/pip_audit.sh, waivers insecurity/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 thesecurityjob. - 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. |