docs: security threat model and review findings

This commit is contained in:
Latte committed 2026-10-14 17:04:47 +02:00
1 parent ce5ae6caa5
commit 5baf73f38b
2 files changed
+665 -82

No files matched your search

+209 -82
View File
@@ -1,108 +1,235 @@
# Security
## Core Controls
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).
- OAuth2/OIDC bearer-token authentication for MCP tool execution.
- OIDC discovery + JWKS validation cache for JWT tokens.
- Userinfo validation fallback for opaque OAuth tokens.
- Scope enforcement:
- `read:repository` for read tools.
- `write:repository` for write tools.
- Policy engine checks before tool execution.
- Per-IP and per-token rate limiting.
- Strict schema validation (`extra=forbid`).
- Tamper-evident audit logging with hash chaining.
- Secret sanitization for logs and tool output.
- Production-safe error responses (no internal stack traces).
## Threat model
## Threat Model
### Assets
### Why shared bot tokens are dangerous
| 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 |
- A single leaked bot token can expose all repositories that bot can access.
- Access is not naturally bounded per end user.
- Blast radius is large and cross-tenant.
### Actors
### Why token-in-URL is insecure
- 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.
- URLs can be captured by reverse proxy logs, browser history, referer headers, and monitoring pipelines.
- Bearer tokens must be passed in `Authorization` headers only.
### Trust boundaries
### Why per-user OAuth reduces lateral access
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.
- Each MCP request executes with the signed-in user token.
- Gitea authorization stays source-of-truth for repository visibility.
- A compromised token is limited to that user�s permissions.
### Threats and mitigations
## Resource-type-aware authorization
Spoofing
The public server runs in *service-PAT mode*: a privileged bot token makes the
actual Gitea calls while the per-user OAuth identity decides what the user may
reach. Repository calls are gated by the user's collaborator permission on
`owner/repo`. The rest of the Gitea surface — reachable through the
`gitea_request` escape hatch — is gated by **resource-type-aware authorization**
(`authz.py`). Every call is classified by `(method, path)` and enforced against
a type-specific rule. **Every decision fails closed**: a call that cannot be
classified, or whose permission cannot be positively verified against Gitea, is
denied and audited.
| 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` |
| Resource type | Rule (service-PAT mode) |
|---------------|--------------------------|
| `repository` | Per-user collaborator permission on `owner/repo` (existing check). A repo path that cannot be parsed to `owner/repo` is denied. |
| `org` | The signed-in user must be a **verified member** of the target org (checked against Gitea, fail closed). |
| `user_owned` | A resource owned by a named user/org (`/users/{name}`, `/packages/{owner}`): allowed only when the owner is the caller, or the caller is a verified member of the owning org. |
| `user_self` | Token-owner-scoped endpoints (`/user`, `/notifications`): **denied** — in service-PAT mode the data belongs to the bot, not the caller. |
| `misc_global` | Instance-wide read-only utilities (markdown render, version, gitignore templates): reads allowed; writes denied. |
| `admin` | **Default deny.** Allowed only when the operator opts in (`RAW_API_ALLOW_SENSITIVE=true`) **and** the signed-in user is a verified Gitea site administrator. |
| `unknown` | Denied. |
Tampering
This gate runs *in addition to* the policy engine and the `WRITE_MODE` gate — a
write call is denied unless write mode is on, policy allows it, and the
resource-type rule passes. In pure-OAuth mode (no service PAT) the user's own
token already scopes every call at Gitea, so the extra gate is unnecessary.
| 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` | none yet | `config.py` (SEC-04) |
Positive verification results (org membership, site-admin) are cached briefly
and bounded; only successful checks are cached, so a transient failure never
grants access.
Repudiation
## Full-API coverage: classified `gitea_request`
| Threat | Mitigation | Implemented in |
|---|---|---|
| Unrecorded tool calls | Exactly one hash-chained audit entry per call; a call is refused when the audit log cannot be opened; startup fails on an unwritable log | `server.py`, `audit.py`, `transports.py` (write failures after startup: SEC-03) |
| Undetected edits of the log | SHA-256 hash chain, `scripts/validate_audit_log.py` | `audit.py` (unkeyed chain: SEC-11) |
`gitea_request` exposes the long tail of the Gitea API that the curated typed
tools do not cover, safely:
Information disclosure
- **Deterministic read/write classifier.** `GET`/`HEAD` are reads; everything
else is a write. A small, explicit override table may only *downgrade*
provably side-effect-free render endpoints (markdown/markup) to reads — never
the reverse — so a mutating call can never be misclassified as a read and slip
past the `WRITE_MODE` gate.
- **Known-path gate.** A request whose top path segment is not a recognized
Gitea `/api/v1` route prefix is denied (fail closed): unknown paths are never
passed straight through.
- **Admin/credential denylist.** `/admin`, `*tokens*`, `*secrets*`, `*hooks*`,
`*keys*`, `applications/oauth2`, and runner registration tokens are blocked for
every method (including `GET`) and cannot be re-opened from `policy.yaml` —
only `RAW_API_ALLOW_SENSITIVE=true` overrides them, and admin then still
requires a verified site administrator (see above).
| 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` |
## Prompt Injection Hardening
Denial of service
Repository content is treated as untrusted data.
| 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) |
- Tool outputs are bounded and sanitized.
- No instructions from repository text are executed.
- Text fields are size-limited before returning to LLM clients.
Elevation of privilege
## Secret Detection
| 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) |
Detected classes include:
### Residual risks and owner decisions
- API key and token patterns.
- JWT-like tokens.
- Private key block markers.
- Common provider credential formats.
- 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.
Behavior:
## Prompt injection
- `SECRET_DETECTION_MODE=mask`: redact in place.
- `SECRET_DETECTION_MODE=block`: replace secret-bearing values.
- `SECRET_DETECTION_MODE=off`: disable sanitization (not recommended).
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. |