SECURITY: ingestion REST routes have no in-process auth; brain_ingest/ingest-path read any server file #149

Closed
opened 2026-10-01 20:56:15 +00:00 by mathias · 4 comments
Owner

Found by the PR #147 gatekeeper review, 2026-10-01. The public half was confirmed live and is mitigated in infra by restricting the ingress paths (infra#517). This issue is the durable code fix.

1. REST mux has no auth

ingestion/cmd/server/main.go wraps only /mcp in chassisauth.BearerMiddleware. /query, /write, /index, /ingest, /ingest-path, /ingest-raw, /backfill-refs, /promote, /backfill-embeddings, /pending, /pass-rate and /metrics have none. After infra#517 they are no longer public, but they are still open to the tailnet (Tailscale Serve koala:8443 → NodePort) and to every pod the NetworkPolicy admits.

Fix: wrap the REST routes in the same BearerMiddleware(mcpToken, jwtValidator, ...). /metrics can stay open in-cluster for Prometheus, or move to its own port. In-cluster callers already send a token (brain-ui BRAIN_MCP_TOKEN, brain-ingest-ntfy BRAIN_TOKEN). Check the watcher and any CronJobs.

2. Arbitrary file read

MCP brain_ingest with path and REST POST /ingest-path call extract.Text(path) on a caller-chosen path. Any file the pod can read (e.g. /var/run/secrets/kubernetes.io/serviceaccount/token) can be ingested into the brain and read back through brain_query.

Fix: resolve the path against an allowlisted root (an inbox directory) with symlink-checked containment, as notes.Resolve does, and refuse everything else. Test with ../ and symlink escapes.

Done when

  • An unauthenticated POST /query returns 401 on the NodePort, with a test.
  • brain_ingest with path: /etc/hostname is refused, with a test.

risk/high: control code (rule 8), needs an adversarial review.


Created via git-mcp on behalf of @mathias

Found by the PR #147 gatekeeper review, 2026-10-01. The public half was confirmed live and is mitigated in infra by restricting the ingress paths (infra#517). This issue is the durable code fix. ## 1. REST mux has no auth `ingestion/cmd/server/main.go` wraps only `/mcp` in `chassisauth.BearerMiddleware`. `/query`, `/write`, `/index`, `/ingest`, `/ingest-path`, `/ingest-raw`, `/backfill-refs`, `/promote`, `/backfill-embeddings`, `/pending`, `/pass-rate` and `/metrics` have none. After infra#517 they are no longer public, but they are still open to the tailnet (Tailscale Serve koala:8443 → NodePort) and to every pod the NetworkPolicy admits. **Fix:** wrap the REST routes in the same `BearerMiddleware(mcpToken, jwtValidator, ...)`. `/metrics` can stay open in-cluster for Prometheus, or move to its own port. In-cluster callers already send a token (brain-ui `BRAIN_MCP_TOKEN`, brain-ingest-ntfy `BRAIN_TOKEN`). Check the watcher and any CronJobs. ## 2. Arbitrary file read MCP `brain_ingest` with `path` and REST `POST /ingest-path` call `extract.Text(path)` on a caller-chosen path. Any file the pod can read (e.g. `/var/run/secrets/kubernetes.io/serviceaccount/token`) can be ingested into the brain and read back through `brain_query`. **Fix:** resolve the path against an allowlisted root (an inbox directory) with symlink-checked containment, as `notes.Resolve` does, and refuse everything else. Test with `../` and symlink escapes. ## Done when - An unauthenticated `POST /query` returns 401 on the NodePort, with a test. - `brain_ingest` with `path: /etc/hostname` is refused, with a test. risk/high: control code (rule 8), needs an adversarial review. --- _Created via git-mcp on behalf of @mathias_
mathias added the risk/highsize/mtype/fix labels 2026-10-01 20:56:15 +00:00
Author
Owner

Pre-push review (infra#470 lane): mathias/infra 7aceee5, verdict PASS

This covers the unpushed commit 7aceee5 on mathias/infra main ("fix(brain-ingest-ntfy): source the brain token from ESO, same item as ingestion"). It is prep for hyperguild#152. The reviewer did not push it.

What I checked, all read-only and by running commands:

  1. Render and diff. kubectl kustomize k3s/apps/brain-ingest-ntfy exits 0 and renders 5 objects. kubectl diff -k shows exactly two changes: the new ExternalSecret brain-ingest-ntfy/brain-mcp-token, and the Deployment's BRAIN_TOKEN secretKeyRef moving from brain-ingest-ntfy-secrets/BRAIN_TOKEN to brain-mcp-token/BRAIN_MCP_TOKEN. Nothing else changes.
  2. Same source as ingestion. The secretStoreRef, target and data[].remoteRef (key: BRAIN_MCP_TOKEN, property: password) match k3s/apps/supervisor/brain-mcp-token-externalsecret.yaml field for field. ClusterSecretStore onepassword is Ready=True ("store validated"), and its spec.conditions is empty, so no namespace restriction applies. The same item already syncs into 4 other namespaces (supervisor, agent-runner, agentsquad-serve, brain-ui), and the existing brain-ingest-ntfy/gitea-registry ExternalSecret on this store is SecretSynced.
  3. Rollout safety. The Deployment has 1 replica and uses RollingUpdate with maxSurge 25% and maxUnavailable 25%, which rounds to surge 1 and unavailable 0. The old pod stays until the new one is Ready. If ESO has not created the Secret yet, the new pod sits in CreateContainerConfigError and the old pod keeps serving. The kubelet retries, so the rollout finishes once the Secret exists. The secretKeyRef is non-optional, so the pod cannot start with an empty token. The worst case is a brief overlap of two subscribers (possible duplicate ingest), not a gap. That overlap happens on every rollout today and this change does not cause it. Flux apps has wait unset, so a stuck rollout would not turn apps NotReady.
  4. App usage. In AGENTS/brain-ingest-ntfy at 65f4572 (the deployed image tag), BRAIN_TOKEN is read in cmd/brain-ingest-ntfy/main.go:32 and used only as Authorization: Bearer in internal/brain/client.go:45. brain-ingest-ntfy-secrets still holds NTFY_TOKEN (32 bytes), and the deployment still references it. That Secret has no labels or annotations (no kustomize.toolkit.fluxcd.io/*) and no manifest in the repo, so Flux does not own it and prune will leave it alone. Its BRAIN_TOKEN key becomes unused. Deleting that key is a separate decision.
  5. Network path. supervisor/allow-callers already admits namespace brain-ingest-ntfy on 3300/TCP.
  6. Secret hygiene. I used only describe and key byte lengths. No values were printed.
  7. task check exits 0.

Flip test. In a scratch worktree I broke both the Deployment's secret name (brain-mcp-tokn) and the remoteRef key (BRAIN_MCP_TOKN) and ran task check: exit 0. No gate in this repo checks that a secretKeyRef points to a Secret that some manifest or ExternalSecret produces, or that a remoteRef names a real 1Password item. The commit has no test, which is acceptable because the change is declarative. But if it regressed, the result would be a silently stuck rollout. Nothing would page, because Flux wait is unset. task check is green, and that green covers none of the properties above.

What I did not check. I did not compare the old hand-made BRAIN_TOKEN value with the 1Password item, because that would mean reading values. It doesn't matter here, because after this change the app's token comes from the same item ingestion uses. I could not observe the live sync of the new ExternalSecret, because it only exists after push. After the push, confirm with kubectl -n brain-ingest-ntfy get externalsecret brain-mcp-token (expect SecretSynced) and check that the rollout completed. Do both before hyperguild#152 deploys. The shared checkout also has an unrelated uncommitted change to k3s/apps/tapir/deployment.yaml. It is not in this commit and was not reviewed.

## Pre-push review (infra#470 lane): mathias/infra 7aceee5, verdict PASS This covers the unpushed commit `7aceee5` on `mathias/infra` main ("fix(brain-ingest-ntfy): source the brain token from ESO, same item as ingestion"). It is prep for hyperguild#152. The reviewer did not push it. **What I checked, all read-only and by running commands:** 1. **Render and diff.** `kubectl kustomize k3s/apps/brain-ingest-ntfy` exits 0 and renders 5 objects. `kubectl diff -k` shows exactly two changes: the new ExternalSecret `brain-ingest-ntfy/brain-mcp-token`, and the Deployment's `BRAIN_TOKEN` secretKeyRef moving from `brain-ingest-ntfy-secrets/BRAIN_TOKEN` to `brain-mcp-token/BRAIN_MCP_TOKEN`. Nothing else changes. 2. **Same source as ingestion.** The `secretStoreRef`, `target` and `data[].remoteRef` (`key: BRAIN_MCP_TOKEN`, `property: password`) match `k3s/apps/supervisor/brain-mcp-token-externalsecret.yaml` field for field. ClusterSecretStore `onepassword` is `Ready=True` ("store validated"), and its `spec.conditions` is empty, so no namespace restriction applies. The same item already syncs into 4 other namespaces (supervisor, agent-runner, agentsquad-serve, brain-ui), and the existing `brain-ingest-ntfy/gitea-registry` ExternalSecret on this store is `SecretSynced`. 3. **Rollout safety.** The Deployment has 1 replica and uses RollingUpdate with maxSurge 25% and maxUnavailable 25%, which rounds to surge 1 and unavailable 0. The old pod stays until the new one is Ready. If ESO has not created the Secret yet, the new pod sits in `CreateContainerConfigError` and the old pod keeps serving. The kubelet retries, so the rollout finishes once the Secret exists. The secretKeyRef is non-optional, so the pod cannot start with an empty token. The worst case is a brief overlap of two subscribers (possible duplicate ingest), not a gap. That overlap happens on every rollout today and this change does not cause it. Flux `apps` has `wait` unset, so a stuck rollout would not turn `apps` NotReady. 4. **App usage.** In `AGENTS/brain-ingest-ntfy` at 65f4572 (the deployed image tag), `BRAIN_TOKEN` is read in `cmd/brain-ingest-ntfy/main.go:32` and used only as `Authorization: Bearer` in `internal/brain/client.go:45`. `brain-ingest-ntfy-secrets` still holds `NTFY_TOKEN` (32 bytes), and the deployment still references it. That Secret has no labels or annotations (no `kustomize.toolkit.fluxcd.io/*`) and no manifest in the repo, so Flux does not own it and prune will leave it alone. Its `BRAIN_TOKEN` key becomes unused. Deleting that key is a separate decision. 5. **Network path.** `supervisor/allow-callers` already admits namespace `brain-ingest-ntfy` on 3300/TCP. 6. **Secret hygiene.** I used only `describe` and key byte lengths. No values were printed. 7. **`task check`** exits 0. **Flip test.** In a scratch worktree I broke both the Deployment's secret name (`brain-mcp-tokn`) and the remoteRef key (`BRAIN_MCP_TOKN`) and ran `task check`: **exit 0**. No gate in this repo checks that a secretKeyRef points to a Secret that some manifest or ExternalSecret produces, or that a remoteRef names a real 1Password item. The commit has no test, which is acceptable because the change is declarative. But if it regressed, the result would be a silently stuck rollout. Nothing would page, because Flux `wait` is unset. *task check is green, and that green covers none of the properties above.* **What I did not check.** I did not compare the old hand-made `BRAIN_TOKEN` value with the 1Password item, because that would mean reading values. It doesn't matter here, because after this change the app's token comes from the same item ingestion uses. I could not observe the live sync of the new ExternalSecret, because it only exists after push. After the push, confirm with `kubectl -n brain-ingest-ntfy get externalsecret brain-mcp-token` (expect SecretSynced) and check that the rollout completed. Do both before hyperguild#152 deploys. The shared checkout also has an unrelated uncommitted change to `k3s/apps/tapir/deployment.yaml`. It is not in this commit and was not reviewed.
Author
Owner

Gatekeeper verdict: BLOCK (head 21a53c4)

I reviewed this by running it, not by reading it. I built the real ingestion/cmd/server binary, started it in an isolated env (sentinel token, OAuth plus capture routes enabled) and attacked it over HTTP and MCP.

What held (verified against the live binary)

  • REST auth. Every REST route returns 401 with no token, a wrong token, Bearer, Bearer (empty), Bearer tok (two spaces), lowercase bearer and Basic, for all methods (GET/HEAD/OPTIONS/PUT/DELETE/PATCH/CONNECT/TRACE). The same holds for unregistered paths and /. These paths return 401 directly: /query/, /%2Fquery, /%71uery, /query;x, /query%00, /QUERY, /mcp%2F..%2Fquery, the absolute-form POST http://evil/query. These get a 307 to the clean path, which then needs auth: //query, /./query, /mcp/../query, /capture/../query, /metrics/../query, /.well-known/../query. /oauth/token refuses empty or wrong credentials. /capture with no auth returns 401. /metrics exposes only per-route latency and status-class counts.
  • Ingest path confinement, REST /ingest-path and MCP brain_ingest both. Refused: .., absolute paths outside raw/, /etc/hostname, /proc/self/environ, abs and rel symlinked files, a symlinked dir and paths through it, a sibling-prefix dir raw-evil/rawx. In a walk, symlinked files are skipped and symlinked dirs are not followed (sentinel not read). A missing raw/ fails closed.

Blocking

  1. The main.go wiring has no test, and the done-when asks for a test of 401 on the NodePort. routes_test.go builds its own replica router. Two flips both stayed green on go test ./cmd/server:
    • replacing the auth closure in main.go:528 with return next
    • adding mux.HandleFunc("POST /query", h.Query) back onto the outer mux. That is the more specific pattern, so /query goes public again.
      Either regression ships green. Suggested fix: pull the outer-mux construction out of main() into a function and test that function. Or assert that every pattern on the outer mux is in an explicit public allowlist.
  2. TestMetricsStaysOpenForPrometheus only checks a handler the test defines itself (rule 9). I wrapped the real /metrics in main.go with BearerMiddleware and the test stayed green.
  3. Caller breakage: hyperguild brain pass-rate now gets 401. cmd/hyperguild/http.go PassRate() never calls c.authorize(req). I reproduced it: with BRAIN_MCP_TOKEN set, it sends no Authorization header and gets status 401. TestBrainClientSendsBrainMCPToken only exercises Query and Write, so its name claims more than it checks and hides this (rule 9). The PR's caller audit also leaves out /pass-rate.

Should fix / follow-up (not the sole reason for the block)

  1. The error text reveals which files exist (authenticated callers only). ingestpath_root.go puts the EvalSymlinks error into the response. ../../../../../../../etc/passwd gives the generic refusal, .../etc/nonexistent-zz gives lstat /etc/nonexistent-zz: no such file or directory, and .../root/.ssh gives permission denied. It also leaks the absolute brain dir. Return the generic error and log the detail.
  2. Other raw/ readers follow symlinks that this PR refuses. This existed before the PR, but now the PR's own claim doesn't hold everywhere. A symlink raw/x.md -> <outside file> is refused by /ingest-path. Yet GET /pending returned the outside file's contents in its excerpt (sentinel found 2 times, bytes not printed). brain_pending uses the same code. brain_promote (promote.go:108) would copy the target into wiki/, and brain-sync would push it. raw/ is writable by a remote party: /var/lib/supervisor/brain is a Syncthing sendreceive folder shared with flamingo, raw/ is not in .stignore, and Syncthing syncs symlinks. It is also writable by uid 1000 on koala and by the brain-* CronJobs that mount the hostPath.
  3. TOCTOU between ResolveIngestPath and extract.Text(path), which reopens the file by name. Exploiting it needs write access to raw/ (see 5) plus a race, so I rate it low. The proper fix is to open beneath the root (os.OpenRoot / openat2 RESOLVE_BENEATH) and extract from that file handle.
  4. The resolver trusts a symlinked raw/ root. With raw -> /, ResolveIngestPath(d, "/etc/hostname") succeeds. That's by design, but nothing documents or tests it.
  5. Stale docs. AGENTS.md (~l.489) says the REST API "remains available" with no mention of auth. docs/runbooks/exercising-pass-rate-traffic.md:27 says /pass-rate needs auth "none (read-only)".

Flip results

Broke Test noticed?
mountREST mounts rest without auth yes (2 fail)
main.go auth closure becomes identity no
main.go re-registers public POST /query no
main.go /metrics wrapped in auth no
ResolveIngestPath drops EvalSymlinks yes
walk re-resolve removed yes
REST handler confinement removed yes
MCP brain_ingest confinement removed yes
CLI Write stops sending the token yes
internal/brain.Query stops sending the token yes

Gates

CI=1 task check in a clean clone: exit 0, 0 FAIL. CI run 8859 (pull_request, 21a53c4) succeeded. CI is green, but green covers none of findings 1-3.

Callers checked: brain-ui (/query) and brain-ingest-ntfy (/ingest) send Bearer <token> when the token is set. Infra 7aceee5 points the ntfy token at the ESO item. agentsquad, dispatch and agent-runner use only /mcp. Watcher and claudewatcher run in-process. ai-sessions/ingest_sessions.py sends the token. Not checked: brain-gardener (no local checkout), and whether the live tokens are equal.

## Gatekeeper verdict: BLOCK (head 21a53c4) I reviewed this by running it, not by reading it. I built the real `ingestion/cmd/server` binary, started it in an isolated env (sentinel token, OAuth plus capture routes enabled) and attacked it over HTTP and MCP. ### What held (verified against the live binary) - **REST auth.** Every REST route returns 401 with no token, a wrong token, `Bearer`, `Bearer ` (empty), `Bearer tok` (two spaces), lowercase `bearer` and `Basic`, for all methods (GET/HEAD/OPTIONS/PUT/DELETE/PATCH/CONNECT/TRACE). The same holds for unregistered paths and `/`. These paths return 401 directly: `/query/`, `/%2Fquery`, `/%71uery`, `/query;x`, `/query%00`, `/QUERY`, `/mcp%2F..%2Fquery`, the absolute-form `POST http://evil/query`. These get a 307 to the clean path, which then needs auth: `//query`, `/./query`, `/mcp/../query`, `/capture/../query`, `/metrics/../query`, `/.well-known/../query`. `/oauth/token` refuses empty or wrong credentials. `/capture` with no auth returns 401. `/metrics` exposes only per-route latency and status-class counts. - **Ingest path confinement**, REST `/ingest-path` and MCP `brain_ingest` both. Refused: `..`, absolute paths outside raw/, `/etc/hostname`, `/proc/self/environ`, abs and rel symlinked files, a symlinked dir and paths through it, a sibling-prefix dir `raw-evil`/`rawx`. In a walk, symlinked files are skipped and symlinked dirs are not followed (sentinel not read). A missing raw/ fails closed. ### Blocking 1. **The `main.go` wiring has no test, and the done-when asks for a test of 401 on the NodePort.** `routes_test.go` builds its own replica router. Two flips both stayed green on `go test ./cmd/server`: - replacing the auth closure in `main.go:528` with `return next` - adding `mux.HandleFunc("POST /query", h.Query)` back onto the outer mux. That is the more specific pattern, so `/query` goes public again. Either regression ships green. Suggested fix: pull the outer-mux construction out of `main()` into a function and test that function. Or assert that every pattern on the outer mux is in an explicit public allowlist. 2. **`TestMetricsStaysOpenForPrometheus` only checks a handler the test defines itself** (rule 9). I wrapped the real `/metrics` in `main.go` with BearerMiddleware and the test stayed green. 3. **Caller breakage: `hyperguild brain pass-rate` now gets 401.** `cmd/hyperguild/http.go` `PassRate()` never calls `c.authorize(req)`. I reproduced it: with `BRAIN_MCP_TOKEN` set, it sends no `Authorization` header and gets `status 401`. `TestBrainClientSendsBrainMCPToken` only exercises Query and Write, so its name claims more than it checks and hides this (rule 9). The PR's caller audit also leaves out `/pass-rate`. ### Should fix / follow-up (not the sole reason for the block) 4. **The error text reveals which files exist** (authenticated callers only). `ingestpath_root.go` puts the EvalSymlinks error into the response. `../../../../../../../etc/passwd` gives the generic refusal, `.../etc/nonexistent-zz` gives `lstat /etc/nonexistent-zz: no such file or directory`, and `.../root/.ssh` gives `permission denied`. It also leaks the absolute brain dir. Return the generic error and log the detail. 5. **Other raw/ readers follow symlinks that this PR refuses.** This existed before the PR, but now the PR's own claim doesn't hold everywhere. A symlink `raw/x.md -> <outside file>` is refused by `/ingest-path`. Yet `GET /pending` returned the outside file's contents in its excerpt (sentinel found 2 times, bytes not printed). `brain_pending` uses the same code. `brain_promote` (`promote.go:108`) would copy the target into wiki/, and brain-sync would push it. **raw/ is writable by a remote party:** `/var/lib/supervisor/brain` is a Syncthing `sendreceive` folder shared with flamingo, raw/ is not in `.stignore`, and Syncthing syncs symlinks. It is also writable by uid 1000 on koala and by the brain-* CronJobs that mount the hostPath. 6. **TOCTOU** between `ResolveIngestPath` and `extract.Text(path)`, which reopens the file by name. Exploiting it needs write access to raw/ (see 5) plus a race, so I rate it low. The proper fix is to open beneath the root (`os.OpenRoot` / openat2 RESOLVE_BENEATH) and extract from that file handle. 7. **The resolver trusts a symlinked raw/ root.** With `raw -> /`, `ResolveIngestPath(d, "/etc/hostname")` succeeds. That's by design, but nothing documents or tests it. 8. **Stale docs.** `AGENTS.md` (~l.489) says the REST API "remains available" with no mention of auth. `docs/runbooks/exercising-pass-rate-traffic.md:27` says `/pass-rate` needs auth "none (read-only)". ### Flip results | Broke | Test noticed? | |---|---| | `mountREST` mounts `rest` without auth | yes (2 fail) | | `main.go` auth closure becomes identity | **no** | | `main.go` re-registers public `POST /query` | **no** | | `main.go` `/metrics` wrapped in auth | **no** | | `ResolveIngestPath` drops EvalSymlinks | yes | | walk re-resolve removed | yes | | REST handler confinement removed | yes | | MCP `brain_ingest` confinement removed | yes | | CLI `Write` stops sending the token | yes | | `internal/brain.Query` stops sending the token | yes | ### Gates `CI=1 task check` in a clean clone: exit 0, 0 FAIL. CI run 8859 (pull_request, 21a53c4) succeeded. CI is green, but green covers none of findings 1-3. Callers checked: brain-ui (`/query`) and brain-ingest-ntfy (`/ingest`) send `Bearer <token>` when the token is set. Infra 7aceee5 points the ntfy token at the ESO item. agentsquad, dispatch and agent-runner use only `/mcp`. Watcher and claudewatcher run in-process. `ai-sessions/ingest_sessions.py` sends the token. Not checked: brain-gardener (no local checkout), and whether the live tokens are equal.
Author
Owner

Gatekeeper verdict, round 2: PASS (head b5c2271). No blocking findings remain.

I rebuilt the real ingestion/cmd/server binary from b5c2271, started it in an isolated env (invented sentinel token, OAuth plus capture enabled) and re-ran the whole round-1 attack set.

Results against the real binary

  • REST auth. Same as round 1. Every REST route, unregistered paths and / return 401 for all methods and all auth-header variants: none, wrong, Bearer, Bearer , two spaces, lowercase, Basic. Encoded, dot-segment and absolute-form paths also return 401. Non-clean paths get a 307 to /query, which still needs the token. A valid token returns 200. /metrics and the OAuth metadata stay open. /oauth/token refuses bad credentials. /mcp and /capture return 401 without auth.
  • Ingest confinement, REST and MCP. Refused: .., absolute paths, /etc/hostname, /proc/self/environ, abs and rel symlinked files, a symlinked dir. A walk skips the inner symlink (outside sentinel count 0).
  • The finding-4 oracle is closed. .../etc/passwd, .../etc/nonexistent-zz and .../root/.ssh now return identical text, with no brain dir in it.

Flip table (round 2)

What I broke Caught?
main.go adds public["POST /query"] yes, at startup. Exit 1, build router: buildMux: "POST /query" is not an allowed public route. The suite stays green; CI can't catch this, the startup check does. The deployment uses the default RollingUpdate, so the old pod keeps serving.
buildMux bearer replaced with return next yes (2 fail)
buildMux allowlist check removed yes
buildMux empty-token check removed yes
POST /query added to publicRoutes yes (2 fail)
mountREST without auth yes (2 fail)
CLI do() drops the Authorization header yes
resolver puts the underlying error back into the refusal yes
resolver without EvalSymlinks yes (3 fail)
main.go wraps buildMux's result in a new outer mux carrying a public POST /query no (residual, see below)
main.go passes mcpSrv to public["/mcp"] unwrapped no (this existed before the PR, see below)

Residuals (not blocking; recorded here so they are not lost)

  • The routes_test comment says "nothing can register a route on the result", and main.go says "the test of buildMux is a test of production wiring". Both are true only of the returned value. A deliberate re-mux in main.go between buildMux and ListenAndServe still ships green. That leaves one untested line of wiring, which is a reasonable bar, but the wording claims slightly more than the test proves.
  • publicRoutes says each public route "authenticates itself". Nothing asserts that for /mcp: its BearerMiddleware is still applied in main.go, and the test router uses stubs. The wiring is the same as on main today. This deserves a follow-up test that /mcp with no token returns 401 through the production assembly.

Gates

  • CI=1 task check in a clean clone at b5c2271: exit 0, 0 FAIL, 44 packages ok.
  • CI run 8882 (pull_request, b5c2271) succeeded. The PR is mergeable.
  • Findings 5–7 are tracked in #153; I confirmed it's open and covers /pending, promote, Syncthing, TOCTOU and the symlinked raw root.
  • Finding 8: PROJECT.md (and regenerated adapters) and the runbook now state the bearer requirement.

Done-when

  • Unauthenticated POST /query returns 401: verified on the real binary, and asserted through buildMux, the function production calls.
  • brain_ingest with /etc/hostname is refused: verified over MCP, and covered by a test.

Note on the merge lane: this is risk/high. Under the gatekeeper procedure, a risk/high merge is Mathias's call. This PASS is the review evidence, not the merge authorisation.

## Gatekeeper verdict, round 2: PASS (head b5c2271). No blocking findings remain. I rebuilt the real `ingestion/cmd/server` binary from b5c2271, started it in an isolated env (invented sentinel token, OAuth plus capture enabled) and re-ran the whole round-1 attack set. ### Results against the real binary - **REST auth.** Same as round 1. Every REST route, unregistered paths and `/` return 401 for all methods and all auth-header variants: none, wrong, `Bearer`, `Bearer `, two spaces, lowercase, `Basic`. Encoded, dot-segment and absolute-form paths also return 401. Non-clean paths get a 307 to `/query`, which still needs the token. A valid token returns 200. `/metrics` and the OAuth metadata stay open. `/oauth/token` refuses bad credentials. `/mcp` and `/capture` return 401 without auth. - **Ingest confinement**, REST and MCP. Refused: `..`, absolute paths, `/etc/hostname`, `/proc/self/environ`, abs and rel symlinked files, a symlinked dir. A walk skips the inner symlink (outside sentinel count 0). - **The finding-4 oracle is closed.** `.../etc/passwd`, `.../etc/nonexistent-zz` and `.../root/.ssh` now return identical text, with no brain dir in it. ### Flip table (round 2) | What I broke | Caught? | |---|---| | main.go adds `public["POST /query"]` | **yes, at startup.** Exit 1, `build router: buildMux: "POST /query" is not an allowed public route`. The suite stays green; CI can't catch this, the startup check does. The deployment uses the default RollingUpdate, so the old pod keeps serving. | | buildMux bearer replaced with `return next` | yes (2 fail) | | buildMux allowlist check removed | yes | | buildMux empty-token check removed | yes | | `POST /query` added to `publicRoutes` | yes (2 fail) | | `mountREST` without auth | yes (2 fail) | | CLI `do()` drops the Authorization header | yes | | resolver puts the underlying error back into the refusal | yes | | resolver without EvalSymlinks | yes (3 fail) | | main.go wraps buildMux's result in a new outer mux carrying a public `POST /query` | **no** (residual, see below) | | main.go passes `mcpSrv` to `public["/mcp"]` unwrapped | **no** (this existed before the PR, see below) | ### Residuals (not blocking; recorded here so they are not lost) - The routes_test comment says "nothing can register a route on the result", and main.go says "the test of buildMux is a test of production wiring". Both are true only of the returned value. A deliberate re-mux in main.go between `buildMux` and `ListenAndServe` still ships green. That leaves one untested line of wiring, which is a reasonable bar, but the wording claims slightly more than the test proves. - `publicRoutes` says each public route "authenticates itself". Nothing asserts that for `/mcp`: its BearerMiddleware is still applied in main.go, and the test router uses stubs. The wiring is the same as on main today. This deserves a follow-up test that `/mcp` with no token returns 401 through the production assembly. ### Gates - `CI=1 task check` in a clean clone at b5c2271: exit 0, 0 FAIL, 44 packages ok. - CI run 8882 (pull_request, b5c2271) succeeded. The PR is mergeable. - Findings 5–7 are tracked in #153; I confirmed it's open and covers /pending, promote, Syncthing, TOCTOU and the symlinked raw root. - Finding 8: PROJECT.md (and regenerated adapters) and the runbook now state the bearer requirement. ### Done-when - Unauthenticated `POST /query` returns 401: verified on the real binary, and asserted through `buildMux`, the function production calls. - `brain_ingest` with `/etc/hostname` is refused: verified over MCP, and covered by a test. **Note on the merge lane:** this is `risk/high`. Under the gatekeeper procedure, a risk/high merge is Mathias's call. This PASS is the review evidence, not the merge authorisation.
Author
Owner

Deployed and verified live. #152 squash-merged as cfe3f24; CD rolled out ingestion:cfe3f24. Mathias approved the merge after the round-2 PASS.

no token with BRAIN_MCP_TOKEN /metrics /mcp no token
LAN 10.0.1.20:30330 POST /query 401, GET /pending 401 200 200 401
tailnet koala:8443 POST /query 401, GET /pending 401 200 200 401

Done-when:

  • Unauthenticated POST /query → 401: verified on both, and asserted through buildMux, which production calls.
  • brain_ingest with /etc/hostname refused: verified by the gatekeeper on the real binary, and covered by a test.

Callers: brain-ingest-ntfy now uses the ESO token (infra 7aceee5, synced and rolled out); brain-ui unchanged; the hyperguild CLI sends the token; ai-sessions repointed (b590f6e). After the deploy, the 4xx counter on the REST routes equals my 4 probes, so no other caller has been refused so far.

Follow-ups: #153 (raw/ symlink readers, Syncthing write path, TOCTOU), #154 (/mcp auth test, re-wrap gap), #155 (chassis v0.2.0 hides rejection logs; metrics label). Closing.


Created via git-mcp on behalf of @mathias

Deployed and verified live. #152 squash-merged as `cfe3f24`; CD rolled out `ingestion:cfe3f24`. Mathias approved the merge after the round-2 PASS. | | no token | with BRAIN_MCP_TOKEN | /metrics | /mcp no token | |---|---|---|---|---| | LAN `10.0.1.20:30330` | POST /query **401**, GET /pending **401** | **200** | 200 | 401 | | tailnet `koala:8443` | POST /query **401**, GET /pending **401** | **200** | 200 | 401 | Done-when: - **Unauthenticated `POST /query` → 401:** verified on both, and asserted through `buildMux`, which production calls. - **`brain_ingest` with `/etc/hostname` refused:** verified by the gatekeeper on the real binary, and covered by a test. Callers: brain-ingest-ntfy now uses the ESO token (infra `7aceee5`, synced and rolled out); brain-ui unchanged; the hyperguild CLI sends the token; ai-sessions repointed (`b590f6e`). After the deploy, the 4xx counter on the REST routes equals my 4 probes, so no other caller has been refused so far. Follow-ups: #153 (raw/ symlink readers, Syncthing write path, TOCTOU), #154 (`/mcp` auth test, re-wrap gap), #155 (chassis v0.2.0 hides rejection logs; metrics label). Closing. --- _Created via git-mcp on behalf of @mathias_
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/hyperguild#149