Files
AegisGitea-MCP/docs/security-findings.md
T

487 lines
36 KiB
Markdown

# Security review findings (S1)
Review of commit `ce5ae6c` on `feat/full-api-coverage`, 2026-10-14. Scope: every path from an
HTTP request or a stdio message to a Gitea call (`transports.py`, `auth.py`, `server.py`,
`oauth/*`, `gitea/*`, `rate_limit.py`, `config.py`), audit and logging, `setup_cmd.py`, the
Docker image and CI. The threat model is in [security.md](security.md). S2 fixes these
findings and adds the negative tests to `tests/security/`.
Severity: critical, high, medium, low. Reproduction scripts referenced below were run in a
scratch directory against the code as committed; each fix lists the negative test that proves
it.
## Summary
| Id | Severity | Title | Status (S2) |
|---|---|---|---|
| SEC-01 | high | Unbounded request body on the unauthenticated consent POST | fixed |
| SEC-02 | medium | Stored webhook `authorization_header` returned by hook read tools | fixed |
| SEC-03 | medium | Audit write failures after startup do not stop later calls | fixed |
| SEC-04 | medium | Settings read a `.env` file from the process working directory | fixed |
| SEC-05 | medium | Any https redirect URI can be registered; the consent page is the only barrier | fixed, default waived (W-10) |
| SEC-06 | medium | Fork pull requests run Docker-capable jobs on the shared runner | fixed |
| SEC-07 | medium | CI actions pinned by mutable tags, one of them receives the registry token | fixed |
| SEC-08 | low | Rate limiting gaps (`/token`, `/revoke`, IPv6, proxy address) | fixed |
| SEC-09 | low | OAuth pre-sign-in state can be flushed by unauthenticated floods | fixed |
| SEC-10 | low | `GITEA_URL` and `PUBLIC_BASE_URL` accept whitespace and control characters; plain http Gitea is silent | fixed |
| SEC-11 | low | Audit log created with umask defaults; unkeyed hash chain | fixed, keyed chain waived (W-11) |
| SEC-12 | low | Tool audit entries do not record the client, session or source IP | fixed |
| SEC-13 | low | Token store directory ownership is not verified | fixed |
| SEC-14 | low | CI cleanup and credential handling on the shared Docker daemon | fixed |
| SEC-15 | low | `tests/security` missing, so the CI `security` job fails; publish is not gated on it | fixed |
| SEC-16 | low | No container image vulnerability scan | fixed |
| SEC-17 | low | Consent POST accepts a missing `Origin` header, unlike ADR 0004 says | fixed |
| SEC-18 | low | Unvalidated argument names and unknown tool names reach the audit log | fixed |
| SEC-19 | low | One fixed cookie name for concurrent sign-ins in the same browser | fixed |
All 19 findings were fixed or waived in S2 (status column and per-finding status lines below); the
negative tests live in `tests/security/`. The fixes found four further defects that are fixed in
the same changeset: secrets inside JSON object keys were not pattern masked (`fef80a5`), a
trailing newline passed the `sudo` pattern (`984673e`), a `data` field on an Action secret
response was not redacted (`421a650`), and configuration errors for `OAUTH_SCOPES` and
`TOOLSETS` echoed the offending values (`0f5f5cd`).
No critical findings. Verified as sound (no finding): static bearer comparison, the identity
split between static bearer, OAuth and no-auth (including OAuth-only deployments), Sudo
handling for OAuth users, path, query and header encoding in the executor, redirect handling,
response size and JSON depth bounds, stdio stdout hygiene, error detail exposure, PKCE, code
and refresh replay handling, consent CSRF and clickjacking headers, the browser-binding
cookie, token store encryption and atomic writes, the setup command's env file handling, and
the Dockerfile (pinned digests, non-root, no secrets in layers).
## Findings
### SEC-01 (high): unbounded request body on the unauthenticated consent POST
- Status: fixed in `d5ca530`: consent and callback routes wrapped in a 16 KiB body limit; 415 for any content type but `application/x-www-form-urlencoded` before a byte is read; form parsed with `max_files=0, max_fields=8, max_part_size=4096`. Tests: `tests/security/test_oauth_input_bounds.py`.
- Where: `src/aegis_gitea_mcp/oauth/callback.py:137-155` (`consent_submit` calls
`await request.form()`), routes at `callback.py:169-173`.
- Description: the browser routes are custom Starlette routes. Unlike the MCP endpoint and the
SDK's `/authorize`, `/token`, `/register` and `/revoke` (all wrapped in
`RequestBodyLimitMiddleware`), they have no body limit, and `uvicorn` sets none. Starlette
parses `multipart/form-data` with up to 1000 fields of up to 1 MiB each held in memory, and
file parts of unlimited size spooled to `/tmp`, which is a RAM-backed tmpfs in the shipped
`docker-compose.yml`. The `Origin` check passes when the header is absent, and the CSRF check
only runs after parsing. Reproduced: one unauthenticated POST with 500 one-megabyte multipart
fields raised the server's RSS from 93 MB to 593 MB; a single 200 MB file part was consumed
completely before the 400 answer. The per-IP limit (120 requests per minute) counts requests,
not bytes, so a single client can exhaust memory and get the process killed. OAuth mode only.
- Fix: wrap the consent and callback routes in `RequestBodyLimitMiddleware` with a small cap
(16 KiB is ample: the form has three short fields); answer 415 before reading the body when
the content type is not `application/x-www-form-urlencoded`; parse with
`request.form(max_files=0, max_fields=8, max_part_size=4096)`.
- Negative test (`tests/security/test_oauth_input_bounds.py`): drive the ASGI app with a
counting `receive`; a 1 MiB urlencoded POST to `/oauth/consent` gets 413 having read at most
the cap plus one chunk; a multipart POST gets 415 having read no body; a valid consent still
works.
### SEC-02 (medium): stored webhook `authorization_header` returned by hook read tools
- Status: fixed in `5b622e6`: `SENSITIVE_RESPONSE_KEYS` (the request-side key list) redacts non-empty string values by key name at any depth in every result except credential-issuing operations, in all `SECRET_DETECTION_MODE` values. Tests: `tests/security/test_secret_echo.py`.
- Where: `src/aegis_gitea_mcp/gitea/executor.py:339-356` and `603-625` (only values submitted
in the request body are redacted, via `sensitive_body_values` in
`src/aegis_gitea_mcp/gitea/encoding.py:387-405`).
- Description: the Gitea `Hook` schema includes `authorization_header`, and Gitea fills it on
list, get and edit. A create call redacts the header because the caller submitted it, but
`gitea_hook_repo_list_hooks`, `gitea_hook_repo_get_hook` and the org, user and admin
equivalents (and `gitea_api_request` on hook paths) return stored headers verbatim to the
model and the remote client. Reproduced with a mocked `repoListHooks` answer: the header
value appears in the result. Pattern masking does not catch `Basic ...` or most `Bearer ...`
values.
- Fix: redact known secret-bearing response fields in `_seal`, before pattern masking: a
non-empty `authorization_header` at any depth becomes `[REDACTED]` (presence stays visible).
Keep the set (`_SENSITIVE_RESPONSE_KEYS`) next to `_SENSITIVE_BODY_KEYS` so new fields are
added in one place. Credential-issuing operations are unaffected (no hook operation is one).
- Negative test (`tests/security/test_secret_echo.py`): list, get and edit hook responses
(repo, org, user, admin, and the raw tool on `/repos/o/r/hooks`) containing
`authorization_header` never contain the value in `content` or `structured_content`, with
`SECRET_DETECTION_MODE` set to `off`, `mask` and `block`.
### SEC-03 (medium): audit write failures after startup do not stop later calls
- Status: fixed in `43eebd3`: the write-ahead alternative: a `tool_call_started` entry is written before every known tool runs and the call is refused with `audit_unavailable` when it fails. Documented in `docs/audit-logging.md`. Tests: `tests/security/test_audit_fail_closed.py`.
- Where: `src/aegis_gitea_mcp/server.py:414-426` (only obtaining the logger is checked) and
`server.py:509-537` (write errors are logged and swallowed);
`src/aegis_gitea_mcp/audit.py:104-107`.
- Description: the fail-closed check only proves the file could be opened once. Once the
singleton exists, a full disk, a revoked mount or any other write error makes every later call
run unaudited, indefinitely, while AGENTS.md promises an audit event for every call. The
post-execution swallow is intentional for the call that already ran, but nothing stops the
next one.
- Fix: record audit health. On a write failure set `AuditLogger.healthy = False`; at the start
of `call_tool`, refuse with `audit_unavailable` while unhealthy, after one probe write
(a small `audit_probe` security event) fails. Alternatively write a `tool_invocation_started`
entry before execution and refuse when it fails. Either way, document the choice in
`docs/audit-logging.md`.
- Negative test (`tests/security/test_audit_fail_closed.py`): after one successful call, make
the log file's `write` raise `OSError`; the next call returns `audit_unavailable` and the
mocked Gitea receives no request; when writes work again, calls resume.
### SEC-04 (medium): settings read a `.env` file from the process working directory
- Status: fixed in `79aa4f7`: `./.env` loads only as a regular file owned by the user with mode 0600; `--env-file` and `AEGIS_ENV_FILE`; documented in README, configuration and troubleshooting. Tests: `tests/security/test_env_file.py`.
- Where: `src/aegis_gitea_mcp/config.py:109-114` (`env_file=".env"`).
- Description: pydantic-settings loads `./.env` for every setting not present in the
environment. Claude Code and other clients start stdio servers with the project directory as
the working directory, so a cloned repository can plant a `.env`. Reproduced: with
`GITEA_URL` and `GITEA_TOKEN` in the environment, a world-readable `.env` in the working
directory set `SECRET_DETECTION_MODE=off`, `EXPOSE_ERROR_DETAILS=true` and
`AUDIT_LOG_PATH=/dev/null` (all calls then go unaudited, and the startup audit check passes).
A client configuration without `GITEA_URL` in the environment would also send the token to an
attacker-chosen `GITEA_URL`.
- Fix: honour `./.env` only when it is a regular file (not a symlink) owned by the current user
and not readable or writable by group or others (the mode `setup` writes, 0600); otherwise
ignore it and print one warning to stderr. Add an explicit `--env-file` option (and
`AEGIS_ENV_FILE`) for other locations. Document it in `docs/configuration.md`.
- Negative test (`tests/security/test_env_file.py`): a 0644 `.env` in the working directory
with `SECRET_DETECTION_MODE=off` and `AUDIT_LOG_PATH=/dev/null` is ignored (mode stays
`mask`, the audit path stays under `DATA_DIR`); a symlinked `.env` is ignored; a 0600 `.env`
owned by the user is honoured; environment values still win.
### SEC-05 (medium): any https redirect URI can be registered; the consent page is the only barrier
- Status: fixed, default waived (W-10) in `8be8592`: owner decision: optional `OAUTH_REDIRECT_ALLOWLIST` (exact URIs or `https://host/` prefixes, checked at registration and `/authorize`); the open default is waived with the consent page as mitigation, and the allowlist is recommended for public deployments. Tests: `tests/security/test_oauth_redirects.py`.
- Where: `src/aegis_gitea_mcp/oauth/provider.py:99-115` (`redirect_uri_problem`),
`provider.py:306-343` (`register_client`), `src/aegis_gitea_mcp/oauth/callback.py:109-135`.
- Description: dynamic client registration is open to anyone and accepts any https redirect
URI and any `client_name`. Gitea remembers consent for our single application, so the only
thing between a phishing link and a victim's full-scope token (default `write:` on every
category, including admin for site admins) is a click on our consent page, which shows an
attacker-chosen name (for example "Claude") and the redirect URI. The design is documented in
ADR 0004; this is an owner decision on residual risk.
- Fix (owner to choose): add `OAUTH_REDIRECT_HOSTS`, an allowlist of redirect hosts accepted at
registration (default: loopback plus the hosted clients the owner uses, for example
`claude.ai` and `chat.mistral.ai`), with a clear registration error otherwise; on the consent
page show the redirect host first and prominently and the client's registration age. Or accept
the risk and record it as a waiver in `docs/security.md`.
- Negative test (`tests/security/test_oauth_redirects.py`): with the allowlist set,
registering `https://evil.example/cb` returns `invalid_redirect_uri`; allowlisted and loopback
URIs register; existing rules (http on non-loopback, fragments, credentials, custom schemes)
stay rejected.
### SEC-06 (medium): fork pull requests run Docker-capable jobs on the shared runner
- Status: fixed in `766968f`: `test-integration`, `test-mcp` and `build-and-push` carry the fork guard. The Gitea setting "require approval for workflows from forks" is a manual step recorded in `docs/deployment.md`. Tests: `tests/security/test_ci_workflows.py`.
- Where: `.gitea/workflows/ci.yml:22-25` (`pull_request` to `dev` and `main`), jobs
`test-integration` (`ci.yml:119-144`) and `test-mcp` (`ci.yml:153-185`).
- Description: a pull request from a fork runs its own workflow and test code, and these jobs
use the runner's Docker daemon (building images, starting containers, joining networks).
Access to the daemon is root-equivalent on the host that other tenants share. Secrets are not
passed to fork runs, but the host is.
- Fix: enable "require approval for workflows from forks" (or the equivalent runner setting) in
Gitea, and guard the Docker jobs with
`if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository`.
Record the setting in `docs/deployment.md` or the CI notes.
- Negative test (`tests/security/test_ci_workflows.py`): parse the workflow YAML; every job that
calls `docker` (or the integration and MCP smoke jobs by name) carries the fork guard.
### SEC-07 (medium): CI actions pinned by mutable tags, one of them receives the registry token
- Status: fixed in `766968f`: every `uses:` is pinned to a full commit SHA with a version comment; `docker/login-action` was replaced by `docker login --password-stdin` into a per-job `DOCKER_CONFIG`; `persist-credentials: false` on every checkout. Tests: `tests/security/test_ci_workflows.py`.
- Where: `.gitea/workflows/ci.yml:40, 63-69, 90-108, 124-130, 160-166, 206, 242, 299-305,
324-330`; `.gitea/workflows/publish.yml:22-28, 47-53, 77-85, 138`. `docker/login-action@v3`
receives `REGISTRY_TOKEN` (`ci.yml:242-248`).
- Description: `actions/checkout@v4`, `actions/setup-python@v5`, `astral-sh/setup-uv@v5`,
`actions/upload-artifact@v4` and `docker/login-action@v3` resolve to whatever the tag points to
at run time. A compromised or retagged action runs with the job's secrets and the Docker
daemon. `actions/checkout` also persists the job token in `.git/config` for later steps.
- Fix: pin every `uses:` to a full commit SHA with the version in a comment; replace
`docker/login-action` with `echo "$REGISTRY_TOKEN" | docker login --username ... --password-stdin`;
set `persist-credentials: false` on checkout where pushing is not needed.
- Negative test (`tests/security/test_ci_workflows.py`): every `uses:` value in
`.gitea/workflows/*.yml` matches `@[0-9a-f]{40}$`; no step passes `secrets.*` to a third-party
action's `with:`.
### SEC-08 (low): rate limiting gaps
- Status: fixed in `f57c237`: `/token` and `/revoke` rate limited; IPv6 clients keyed by /64; `ForwardedHeadersMiddleware` applies forwarded headers for `FORWARDED_ALLOW_IPS` peers and logs one warning for any other peer that sends `X-Forwarded-For`. Tests: `tests/security/test_rate_limits.py`.
- Where: `src/aegis_gitea_mcp/oauth/components.py:37` (`RATE_LIMITED_PATHS` lacks `/token` and
`/revoke`), `src/aegis_gitea_mcp/rate_limit.py:142-163` (per-IP key is the full address),
`src/aegis_gitea_mcp/transports.py:168-188`, `transports.py:400-419`.
- Description: `/token` and `/revoke` (client secret checks, store writes) are not limited. Each
IPv6 address is its own bucket, so a client with a /64 is effectively unlimited. Behind a
proxy whose address is not in `FORWARDED_ALLOW_IPS` (the default `127.0.0.1` does not match a
Docker network), every request shares the proxy's bucket, and because the limit runs before
authentication, any unauthenticated client can exhaust the budget of all authenticated users.
- Fix: add `/token` and `/revoke` to `RATE_LIMITED_PATHS`; key IPv6 clients by their /64; log a
one-time warning when a request carries `X-Forwarded-For` from a peer outside
`FORWARDED_ALLOW_IPS`.
- Negative test (`tests/security/test_rate_limits.py`): limit+1 requests to `/token` get 429;
two addresses in one IPv6 /64 share a bucket; a forwarded header from an untrusted peer logs
the warning once.
### SEC-09 (low): OAuth pre-sign-in state can be flushed by unauthenticated floods
- Status: fixed in `87f048c`: per client address at most 20 pending sign-ins and 20 registrations per hour; clients younger than `PENDING_TTL` are never evicted; the store write runs in a worker thread. Tests: `tests/security/test_oauth_input_bounds.py`.
- Where: `src/aegis_gitea_mcp/oauth/provider.py:139-159` (`_ExpiringMap.put` evicts the oldest
entry), `provider.py:80-85`, `provider.py:333-356` (client eviction),
`src/aegis_gitea_mcp/oauth/store.py:250-275` (synchronous fsync on the event loop).
- Description: 1024 `/authorize` requests (needs only a self-registered client) evict every
legitimate pending sign-in; 500 registrations evict recently registered clients that have
not finished signing in. Every registration writes and fsyncs the whole store on the event
loop. Impact: sign-in denial of service, no data exposure.
- Fix: per-IP quota on pending requests and registrations (for example 20 each); never evict a
client younger than `PENDING_TTL`; run `OAuthStore._write` in a worker thread
(`anyio.to_thread.run_sync`).
- Negative test (`tests/security/test_oauth_input_bounds.py`): 1100 `/authorize` calls from one
IP do not evict a pending request from another IP; a client registered a minute ago survives
a registration flood.
### SEC-10 (low): base URLs accept whitespace and control characters; plain http Gitea is silent
- Status: fixed in `d96c3ac`: whitespace, backslash and control characters rejected in both URLs, the URL is rebuilt from its parsed parts, `serve` warns for plain http to a non-loopback Gitea (a warning rather than a refusal, so existing lab setups keep working). Tests: `tests/security/test_base_url_validation.py`.
- Where: `src/aegis_gitea_mcp/config.py:87-103` (`_normalize_http_url` validates the parsed URL
but returns the raw string), `config.py:180-201`.
- Description: `https://gi\ttea.example.com`, `https://gitea.example.com/a\nb` and values with
spaces pass validation; `urlsplit` strips tabs and newlines for the check while the raw value
is used later, so the validated and the used URL can differ. A plain `http://` `GITEA_URL` to a
non-loopback host is accepted at serve time without any warning, although the admin token, the
OAuth client secret and user tokens then travel in clear text (setup warns, `serve` does not).
- Fix: reject any whitespace or non-printable character in both URLs; return the URL rebuilt
from its parsed parts; print a startup warning (or refuse unless an explicit
`ALLOW_INSECURE_GITEA_URL=true`) for http to a non-loopback host.
- Negative test (`tests/security/test_base_url_validation.py`): parametrized rejection of tab,
newline, carriage return, space, NUL and bidi controls in `GITEA_URL` and `PUBLIC_BASE_URL`;
existing rejections (scheme, credentials, query, fragment, bad port, missing host, http public
base URL) kept; `serve` with `GITEA_URL=http://gitea.internal` prints the warning.
### SEC-11 (low): audit log created with umask defaults; unkeyed hash chain
- Status: fixed, keyed chain waived (W-11) in `7307214`: directory 0700, log opened `O_NOFOLLOW` with mode 0600, an older loose log is tightened, startup exits 2 for a symlink. The optional `AUDIT_HMAC_KEY` was not added: off-host shipping is the documented integrity control. Tests: `tests/security/test_audit_file.py`.
- Where: `src/aegis_gitea_mcp/audit.py:36-39`, `audit.py:64-69`.
- Description: `DATA_DIR` is created with the default mode (0755 under a 022 umask) and the
audit log with 0644; only the OAuth store later tightens the directory, so in admin-token mode
other local users can read logins, paths and timing of every call. The audit path is opened
without `O_NOFOLLOW`. The SHA-256 chain has no key, so anyone with write access can rewrite
it consistently, and truncating the tail is not detectable.
- Fix: create the directory with 0700 and open the log with
`os.open(path, O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600)`; document off-host
shipping as the integrity control, optionally add an `AUDIT_HMAC_KEY` for a keyed chain.
- Negative test (`tests/security/test_audit_file.py`): after the first call the log is 0600 and a
newly created `DATA_DIR` is 0700; a symlink at the audit path makes startup exit 2.
### SEC-12 (low): tool audit entries do not record the client, session or source IP
- Status: fixed in `fd5e4b7`: tool entries carry `client_id`, a 12 character `session` hash and `client_ip`. Tests: `tests/security/test_audit_fail_closed.py`.
- Where: `src/aegis_gitea_mcp/server.py:509-537`.
- Description: in OAuth mode an entry names the Gitea login but not which MCP client or grant
made the call, nor the source address, which limits incident response (for example finding
which registered client a stolen token belonged to).
- Fix: add `client_id` from the verified access token, a short hash of the session id and the
client IP (when on HTTP) to the tool invocation entry.
- Negative test (`tests/security/test_audit_fail_closed.py` or a sibling): an OAuth call's entry
contains the client id and no token or session id in clear.
### SEC-13 (low): token store directory ownership is not verified
- Status: fixed in `6f7a3c0`: the store directory must be owned by the effective user and must not stay group or world writable; checked on load and on every save. Tests: `tests/security/test_token_store.py`.
- Where: `src/aegis_gitea_mcp/oauth/store.py:133-143`.
- Description: an existing `DATA_DIR` owned by another user, or one that stays group or world
writable after the chmod attempt, only produces a warning. Whoever controls the directory can
replace the store with an older copy (resurrecting revoked grants) or delete it.
- Fix: refuse (raise `OAuthStoreError`) when the directory's owner is not the effective user or
it is still writable by group or others.
- Negative test (`tests/security/test_token_store.py`): with a directory reported as owned by
another uid (monkeypatched `os.stat`) or mode 0777 that cannot be tightened, loading or saving
fails with a clear message.
### SEC-14 (low): CI cleanup and credential handling on the shared Docker daemon
- Status: fixed in `766968f`: cleanup removes containers only for per-run refs (`aegis-ci-*`, `:sha-*`) and only untags shared tags; the credential directory is per job; `uv publish` reads `UV_PUBLISH_PASSWORD`. Tests: `tests/security/test_ci_workflows.py` (fake `docker` on `PATH`).
- Where: `scripts/ci/cleanup_job_resources.sh:43-48`, `.gitea/workflows/ci.yml:281-290`,
`.gitea/workflows/publish.yml:150-157`.
- Description: the cleanup removes every container whose `ancestor` is one of the given refs,
and the build job passes the shared tags `:latest`, `:main` and `:dev`; on a daemon that also
runs a deployment of this image that can remove containers that are not the job's.
`docker logout` acts on the shared credential store when the job is not containerized.
`uv publish --password "${REGISTRY_TOKEN}"` puts the token on the command line.
- Fix: remove containers only for per-run refs (`aegis-ci-<run id>-*` and the job's own
`:sha-` tag); for shared tags only untag (`docker image rm` of the tag); pass the publish
token through `UV_PUBLISH_PASSWORD`.
- Negative test (`tests/security/test_ci_workflows.py`): run the cleanup script with a fake
`docker` on `PATH` that records argv; `rm -f` is only issued for containers of per-run refs;
`publish.yml` contains no `--password` argument.
### SEC-15 (low): `tests/security` missing; publish not gated on the security job
- Status: fixed in `766968f`: `tests/security` exists (this suite) and `publish.yml` has a `security` job that `publish` needs (gitleaks, security tests, pip-audit, ruff `S`). Tests: `tests/security/test_ci_workflows.py`.
- Where: `.gitea/workflows/ci.yml:337`, `.gitea/workflows/publish.yml` (needs only lint and
test).
- Description: `pytest tests/security` exits with code 4 (path not found), so the CI `security`
job fails today. The publish workflow ships packages without gitleaks, pip-audit or the
security tests.
- Fix: S2 creates the suite listed below; add a `security` job (pip-audit, ruff `S`, security
tests, gitleaks) to `publish.yml` and make `publish` need it.
- Negative test: the suite itself; a CI workflow test asserting `publish` needs the security
job.
### SEC-16 (low): no container image vulnerability scan
- Status: fixed in `766968f`: Trivy 0.76.0 pinned with sha256 (`scripts/ci/install_trivy.sh`) scans the built image in `build-and-push` before the push and fails on fixable HIGH or CRITICAL findings (`scripts/ci/scan_image.sh`, waivers in `security/trivyignore.txt`); `scan.yml` repeats it weekly as the digest refresh reminder. Verified locally against the built image (clean).
- Where: `docker/Dockerfile:6-7`, `.gitea/workflows/ci.yml` (`build-and-push`).
- Description: base image digests are pinned and refreshed by hand; OS package advisories in
the image are never checked.
- Fix: add a scan step (Trivy or Grype pinned by digest) on the built image, failing on high and
critical with a waiver file, and a scheduled digest refresh reminder.
- Negative test: CI only (the step runs and fails on a known vulnerable test image).
### SEC-17 (low): consent POST accepts a missing `Origin` header
- Status: fixed in `782d416`: consent POST needs an `Origin` equal to the public origin, with the `Referer` origin as fallback; missing, `null` or foreign values are refused. Tests: `tests/security/test_oauth_csrf.py`.
- Where: `src/aegis_gitea_mcp/oauth/callback.py:138-140`; ADR 0004 says approval needs "a
matching `Origin`".
- Description: the `SameSite=Strict` double-submit cookie is the effective CSRF control and is
sound, but the code and the ADR disagree. Browsers send `Origin` on form POSTs.
- Fix: require `Origin` to equal the public origin (fall back to `Referer` when `Origin` is
absent), or correct the ADR.
- Negative test (`tests/security/test_oauth_csrf.py`): POST without `Origin` and `Referer`,
with `Origin: null`, and with a foreign origin all get 400 and no redirect.
### SEC-18 (low): unvalidated argument names and unknown tool names reach the audit log
- Status: fixed in `fd5e4b7`: only argument names that are schema properties are recorded (rest counted as `rejected_argument_count`); unknown tool names are recorded as `(unknown tool, N chars)` and not echoed. Tests: `tests/security/test_audit_fail_closed.py`.
- Where: `src/aegis_gitea_mcp/server.py:412-413` (argument names recorded before validation),
`server.py:428-431` (unknown tool name echoed and audited).
- Description: validation messages carefully avoid caller-chosen argument names because "they
could carry a secret", but the audit entry records them before validation, and an unknown tool
name (up to 128 characters) is written to the audit log and echoed in the error. Pattern
masking still runs, so only non-pattern secrets are affected.
- Fix: record only argument names that are properties of the tool's schema (plus a count of
rejected ones); record unknown tool names as a fixed marker plus length, and do not echo them.
- Negative test (`tests/security/test_redaction.py`): a call with an extra argument named like a
40-hex token, and a call to a tool named like one, leave neither value in the audit file or
the error.
### SEC-19 (low): one fixed cookie name for concurrent sign-ins in the same browser
- Status: fixed in `782d416`: the binding cookie is named per flow (`aegis-oauth-flow-<8 hex of the state hash>`). Tests: `tests/security/test_oauth_csrf.py`.
- Where: `src/aegis_gitea_mcp/oauth/callback.py:56-60`, `callback.py:150-155`.
- Description: a second sign-in started in the same browser overwrites the first flow's binding
cookie. The first callback then fails and writes a high-severity
`oauth_callback_binding_mismatch` audit event, a false alarm that trains operators to ignore
the one event that signals a confused-deputy attempt.
- Fix: suffix the binding cookie name with a short hash of the Gitea `state`
(`__Host-aegis-oauth-flow-<8 hex>`), and look it up by the state on the callback.
- Negative test (`tests/security/test_oauth_csrf.py`): two interleaved flows in one browser
both complete; a callback without its own cookie still fails and is audited.
## Negative tests S2 added to `tests/security/`
All of the files below exist now (the list is kept as the specification they were written
from). Results are in the "Verification results" section of [security.md](security.md).
Some of these duplicate existing unit tests on purpose: the security suite is the one CI and
reviewers read, and it must stay green independently of refactors elsewhere. Reuse the helpers
in `tests/test_oauth_helpers.py` and `tests/test_executor_helpers.py`.
1. `test_auth_required.py`
- `/mcp` without `Authorization` gets 401 with `WWW-Authenticate` (bearer-only app and OAuth
app).
- Wrong bearer gets 401: same length, shorter, longer, empty, wrong scheme (`Basic`, `token`),
trailing whitespace, the bearer of another deployment.
- The static bearer is compared with `hmac.compare_digest` on digests (spy).
- OAuth: expired, revoked, other-resource and unknown `agm_` tokens get 401; an `agm_` token
never resolves to the admin identity (Gitea mock sees the user's token only).
- Route inventory: the set of routes reachable without authentication is exactly
`/`, `/health`, the `.well-known` documents and the OAuth routes; adding a route fails the
test.
2. `test_startup_fail_closed.py`
- Non-loopback binds (`0.0.0.0`, `::`, a public IP, a host name, `127.1`, `0`) without bearer
or OAuth exit 2 and never call `uvicorn.run`.
- Half-configured OAuth, a bearer shorter than 32 characters, an http `PUBLIC_BASE_URL` on a
public host, an unwritable audit log and (SEC-11) a symlinked audit path all exit 2.
3. `test_redaction.py`
- With all secrets configured (`GITEA_TOKEN`, `MCP_BEARER_TOKEN`, `GITEA_OAUTH_CLIENT_SECRET`,
`TOKEN_ENCRYPTION_KEY`, a user's Gitea access and refresh tokens, an issued `agm_` token),
drive a Gitea error that echoes each of them and a success body that contains each of them:
none appears in the result, the error message, the audit file, or captured logs at DEBUG.
- `uvicorn.access` records for `/oauth/callback?code=...&state=...` and
`/oauth/consent?request=...` contain `[redacted]` instead of the query.
- `repr` of `Settings`, `Identity`, `SessionRecord`, `UpstreamTokens` and
`PendingAuthorization` contains no secret.
- Validation errors never contain argument values (SEC-18 for names).
4. `test_secret_echo.py`
- `gitea_actions_repo_list_secrets` and `gitea_actions_org_list_secrets` return the names
Gitea sends; the update tools never echo the submitted `data`, including in a Gitea 422
error that repeats it.
- SEC-02: hook list, get and edit responses never contain `authorization_header` values, in
every `SECRET_DETECTION_MODE`.
- Submitted `password`, `secret`, `token`, `authorization_header` and migration credentials
are redacted from results.
- Credential-issuing operations return the new credential (documented exemption, W-07) but
the audit file and logs do not contain it.
5. `test_malformed_input.py`
- Unknown arguments, wrong types, missing required arguments rejected before Gitea is
contacted.
- Path values with `..`, `.`, empty segment, `\`, `?`, `#`, `%`, `%2e`, NUL, CR, LF, U+2028
rejected for catalog path parameters and for the raw tool path; a raw path starting `//`
rejected.
- Query names `token`, `access_token`, `sudo`, `_sudo` in any case rejected.
- `sudo` with CR/LF, spaces or over 255 characters rejected.
- Upload: oversized base64 rejected before decoding, invalid base64, filenames with quotes or
CR/LF, unknown file keys.
- Raw method outside the enum; `READ_ONLY` with a non-GET raw call.
- Deeply nested JSON arguments and bodies give an error result, never an exception.
- HTTP: body over the cap gets 413, non-JSON content type 400, foreign `Host` 421, foreign
`Origin` 403.
6. `test_base_url_validation.py`
- `GITEA_URL` and `PUBLIC_BASE_URL`: non-http schemes, credentials, query, fragment, bad port,
missing host, whitespace and control characters (SEC-10), http public base URL on a public
host; trailing `/api/v1` normalized.
- `MCP_PATH`, `FORWARDED_ALLOW_IPS` and `OAUTH_SCOPES` (unknown scope) rejections.
- Redirect URIs at registration: http on non-loopback, fragment, credentials, `javascript:`,
custom schemes rejected.
7. `test_oauth_csrf.py` (SEC-17, SEC-19): consent without the CSRF cookie, with a mismatched
token, without or with a foreign `Origin`; callback without the binding cookie, with a
replayed or expired state; none of them redirects.
8. `test_oauth_input_bounds.py` (SEC-01, SEC-09).
9. `test_audit_fail_closed.py` and `test_audit_file.py` (SEC-03, SEC-11, SEC-12).
10. `test_env_file.py` (SEC-04).
11. `test_token_store.py`: file mode 0600, directory 0700, symlink refused, wrong key stops
startup without wiping, store content is not plaintext, our tokens only as hashes, SEC-13.
12. `test_rate_limits.py` (SEC-08).
13. `test_ci_workflows.py` (SEC-06, SEC-07, SEC-14, SEC-15).
## B5 checker verdict
Verdict: CHANGES REQUIRED (at `ce5ae6c`; the blocking item was fixed in S2, `d5ca530`, and
every non-blocking item below was fixed or waived, see the status column above).
Blocking:
- SEC-01: the unauthenticated consent POST reads unbounded multipart bodies into memory and
tmpfs. This is B5 code and reproducible with one request. Fix and its negative test are
required before B5 is accepted.
Non-blocking (fix in S2, or record as waivers): SEC-05 (redirect allowlist, owner decision),
SEC-09, SEC-12, SEC-13, SEC-17 (code or ADR), SEC-19.
What was checked and holds:
- Identity separation: an access token with the OAuth marker always resolves to its own
session or fails; it never falls back to the admin token, even when the session ends or the
upstream refresh fails. Only the static bearer (or no token on stdio and unauthenticated
loopback) reaches admin-token mode; an OAuth-only deployment fails closed for it. The session
login is checked against the token subject.
- Authorization server: DCR client ids are minted by the SDK; PKCE S256 is mandatory towards
clients and used towards Gitea; redirect URIs are validated at registration and matched by the
SDK; codes are single use, bound to the client, replay revokes the grant; refresh tokens rotate
and reuse revokes the grant; revocation is limited to the owning client by the SDK; tokens are
bound to the MCP resource and scope.
- Browser flow: consent CSRF double submit with a `SameSite=Strict` `__Host-` cookie, a
browser-binding cookie on the callback, single-use states, no redirect on any invalid callback,
`no-store`, `no-referrer`, `DENY` framing and CSP on every page, HTML escaping of the client
name and redirect URI, no request parameter echoed.
- Upstream: confidential client with form-encoded credentials, no redirects followed, coarse
error kinds only, login validated against a strict pattern, token type and length checked,
unknown Gitea scopes rejected at configuration.
- Store: Fernet with an HKDF-SHA256 key, mode 0600 through `O_EXCL` temporary files and atomic
rename, symlinks refused, a wrong key or corrupt file stops startup without wiping, our tokens
stored only as hashes, pruning of expired tokens and abandoned sessions, bounded clients and
sessions per login.
- Secret handling: the user's refresh token, the store key and the client secret are redacted
from results; issued `agm_` tokens are masked by pattern; access-log query strings are dropped.
- Quality gates at `ce5ae6c`: 157 OAuth tests and the whole unit suite pass (846 passed, 23
skipped), coverage 98.2 percent, `ruff check`, `ruff format --check` and `mypy` clean. The docs
drift check failed only on the stale `docs/security.md`, replaced in this changeset.