236 lines
18 KiB
Markdown
236 lines
18 KiB
Markdown
# 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](hardening.md); open review findings are in
|
|
[security-findings.md](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: SEC-05) |
|
|
| 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`.
|
|
- The consent page is the only barrier against phishing with a self-registered client
|
|
(SEC-05).
|
|
- 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](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 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, `.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](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. |
|