Security review: middleware and agent — authn/authz, HMAC signing, secrets, SSRF #68

Closed
opened 2026-10-07 20:35:02 +02:00 by gotoi-agent · 5 comments
Collaborator

Goal

Security-focused review of middleware/ and agent/: identify concrete vulnerabilities with
severity, evidence and a proposed fix. Critical/high issues get a fix + regression test on this
branch; the full findings go in the issue comment.

Scope

  1. Authn/authz surface: Better Auth wiring (routes/auth.ts), organizationGuard,
    isInstanceOperator, org membership → object access (access.ts). Probe for IDOR:
    object-id access across orgs, share links, invites (UC-MW-143), enroll tokens.
  2. Agent path-token auth (/agent/:id/:token/*): token entropy, presence in logs/URLs/error
    bodies, replay window, capabilities endpoint abuse.
  3. HMAC job/plan signing (plan-artifact.ts, UC-MW-85): is every verified input covered by
    the signed payload (planId|version|commit|executionId|issuedAt)? Tamper tests: altered fields
    that still validate = finding.
  4. Secrets: encryptSecret storage/retrieval, API keys in settings KV, GET endpoints
    returning secrets (there is a UC asserting the stored API key never leaks — attack it),
    GOTOI_PLAN_SECRET / GOTOI_AUTH_SECRET handling in errors.
  5. SSRF / injection: pull sources (pull.ts, providers/) fetching operator-supplied URLs,
    webhook payload → shell/SQL/template paths, control-agent (control-agent.ts) command/tool
    argument handling, agent/pi proposal path.
  6. Dashboard proxy (proxy.ts): header smuggling past HOP_BY_HOP, path traversal on
    pass-through routes.

Rules of engagement

  • Local only: test against the dev stack / test harness (PGlite-injected buildApp), never
    against a shared or production deployment; no DoS, no state destruction outside test fixtures.
  • No exploit code for its own sake — a repro must be a minimal test in middleware/tests/ or a Go
    test, whatever proves the claim.
  • Report severity honestly: Critical (auth bypass, RCE, cross-tenant write), High (secret
    leak, IDOR read), Medium, Low. Overstated severities are a finding against the report.
  • Do not publish exploitation details beyond this issue thread — it is a private repo, but write
    as if it will be read widely; the fix commit message describes the class, not the exploit.
  • Cross-check docs/agent-pipeline/threat-model.md and anti-tampering.md: state which threats
    are already covered and which you disproved.
  • Refs #<N> only. One defect per commit. Criticals fixed here; the rest listed for triage.
## Goal Security-focused review of `middleware/` and `agent/`: identify concrete vulnerabilities with severity, evidence and a proposed fix. Critical/high issues get a fix + regression test on this branch; the full findings go in the issue comment. ## Scope 1. **Authn/authz surface**: Better Auth wiring (`routes/auth.ts`), `organizationGuard`, `isInstanceOperator`, org membership → object access (`access.ts`). Probe for IDOR: object-id access across orgs, share links, invites (`UC-MW-143`), enroll tokens. 2. **Agent path-token auth** (`/agent/:id/:token/*`): token entropy, presence in logs/URLs/error bodies, replay window, capabilities endpoint abuse. 3. **HMAC job/plan signing** (`plan-artifact.ts`, `UC-MW-85`): is every verified input covered by the signed payload (`planId|version|commit|executionId|issuedAt`)? Tamper tests: altered fields that still validate = finding. 4. **Secrets**: `encryptSecret` storage/retrieval, API keys in settings KV, `GET` endpoints returning secrets (there is a UC asserting the stored API key never leaks — attack it), `GOTOI_PLAN_SECRET` / `GOTOI_AUTH_SECRET` handling in errors. 5. **SSRF / injection**: pull sources (`pull.ts`, `providers/`) fetching operator-supplied URLs, webhook payload → shell/SQL/template paths, control-agent (`control-agent.ts`) command/tool argument handling, `agent/pi` proposal path. 6. **Dashboard proxy** (`proxy.ts`): header smuggling past `HOP_BY_HOP`, path traversal on pass-through routes. ## Rules of engagement - **Local only**: test against the dev stack / test harness (PGlite-injected `buildApp`), never against a shared or production deployment; no DoS, no state destruction outside test fixtures. - No exploit code for its own sake — a repro must be a minimal test in `middleware/tests/` or a Go test, whatever proves the claim. - **Report severity honestly**: Critical (auth bypass, RCE, cross-tenant write), High (secret leak, IDOR read), Medium, Low. Overstated severities are a finding against the report. - Do not publish exploitation details beyond this issue thread — it is a private repo, but write as if it will be read widely; the fix commit message describes the class, not the exploit. - Cross-check `docs/agent-pipeline/threat-model.md` and `anti-tampering.md`: state which threats are already covered and which you disproved. - `Refs #<N>` only. One defect per commit. Criticals fixed here; the rest listed for triage.
gotoi-agent added reference issue/68-security-review 2026-10-07 23:53:46 +02:00
Author
Collaborator

Claimed — branch issue/68-security-review. Doing: security sweep of middleware+agent (authn/authz, HMAC signing, secrets, SSRF), fix Critical/High with regression tests, full findings list in Done comment.

Claimed — branch `issue/68-security-review`. Doing: security sweep of middleware+agent (authn/authz, HMAC signing, secrets, SSRF), fix Critical/High with regression tests, full findings list in Done comment.
Author
Collaborator

Issue #68 — security review of middleware/ and agent/ (authn/authz, HMAC, secrets, SSRF)

Restated goal

Audit the six scope areas (authn/authz, agent path-token auth, HMAC job signing, secrets, SSRF/injection, dashboard proxy) and fix the Critical/High findings on this branch with a red-first regression test each; every other finding is listed for triage in the Done comment. "Done" for the operator = a smaller verified diff of the exploitable defects, plus one reviewable findings list covering all six areas, including the negative results (threats checked and disproved). Severity is reported honestly; an overstated severity is itself a finding.

Scope

Changed here (Critical/High, each with a failing-then-passing test):

# Finding File
F1 GET /plans/ai/config instance-operator gate is dead code (blanket GET/HEAD → viewer line precedes it) middleware/src/app.ts
F2 POST /agent/:id/:token/result accepts a result for any execution (no agent-ownership / org check) middleware/src/routes/agent-api.ts
F3 GET /agent/:id/:token/archive/:planId serves any org's repo plan (no org check) middleware/src/routes/agent-api.ts
F4 Agent path token written verbatim into the request log (logger: true, no serializers) middleware/src/app.ts, middleware/src/ops.ts
F5 Captured workflow value is substituted unquoted into sh -c → command injection on every agent host agent/workflow/workflow.go
F6 find -fprintf/-fprint/-fprint0/-fls classify as read-only → bypasses plan mode middleware/src/command-risk.ts
F7 proposedBy: turn.model — undefined identifier (runtime ReferenceError) middleware/src/agent-step.ts

Not touched: plans/ (product content), dashboard/ (proxy upstreamUrl strips leading slashes and SvelteKit already normalises .. on the server side; no exploitable difference found — recorded as a negative result), the HMAC payload contract (plan-artifact.ts ↔ agent/verify/verify.go; changing it is a wire-contract break, listed for triage), AgentCapabilities zod tightening (Medium, listed), any new dependency, abstraction or config knob.

UC decision

Fix UC Action
F1 UC-MW-167 (ai-config) bump version:, correct the Story/Notes so the claimed instance gate is true
F2, F3 new UC-MW-170 — "Agent route ownership and org scoping" new file docs/use-cases/middleware/UC-MW-170-agent-route-ownership.md, Implementation cites middleware/src/routes/agent-api.ts
F4 UC-OP-09 (token redaction in logs) bump version:, Notes: the helper is now wired into the Fastify logger; Implementation cites middleware/src/ops.ts
F5 UC-AG-64 (workflow v2) bump version:, Story: capture values reach ${VAR} through the child environment, never through string substitution
F6 UC-MW-147 (command policy) bump version:, Notes: the find mutate set
F7 UC-MW-166 (agent block) bump version:, Notes: the model id comes from the AI dependency, not a turn object

Test-file rule: the branch changes middleware/tests/* and agent/workflow/workflow_test.go, and each change ships with the UC edit above in the same branch (satisfies check-test-tampering.py test-change-without-uc; no assertion is removed, no new skip).

Ordered tasks (each with its single verification command)

  1. F1 — plan + Plan comment. Write this file, post it, then add an auth-on case to middleware/tests/uc-mw-167-ai-config.test.ts: build with auth: AUTH, createMember + setActiveOrg, GET /plans/ai/config → expect 403. Watch it fail (200), then move the GET /plans/ai/config rule above the blanket GET/HEAD line in neededRole. Verify: cd middleware && bun test tests/uc-mw-167-ai-config.test.ts && python3 docs/use-cases/_check.py.
  2. F2 + F3 — agent route ownership. Add middleware/tests/uc-mw-170-agent-route-ownership.test.ts: (a) two enrollments; agent A claims a job and reports it, agent B POSTs a result for A's execution → expect 403 and A's execution unchanged in built.stores.executions; (b) repo plan owned by org A, agent enrolled under org B requesting archive → expect 404. Add the two guards in agent-api.ts (exec.agentId && exec.agentId !== id → 403; plan org/shared check → 404). Verify: cd middleware && bun test tests/uc-mw-170-agent-route-ownership.test.ts && bun test.
  3. F4 — token redaction in logs. Export the path rewriter next to redactToken in ops.ts, wire it as the pino req serializer in buildApp; widen BuildAppOptions.logger to boolean | FastifyLoggerOptions. Test in uc-ops.test.ts: unit case on the helper, plus an app built with logger: { stream: { write } } that polls a job and asserts no captured line contains the token. Verify: cd middleware && bun test tests/uc-ops.test.ts tests/uc-mw-http.test.ts.
  4. F5 — workflow injection. Red-first test in agent/workflow/workflow_test.go: step a captures the string $(touch pwned), step b runs echo ${X}; assert pwned was not created. Fix = drop the manual ${VAR} substitution for run: and let the child environment (already built by envOf) do it. Verify: cd agent && go test ./... && gofmt -l agent.
  5. F6 + F7 — cheap hardening. FIND_MUTATE additions + a find … -fprintf case in uc-mw-control-hardening.test.ts; turn.model → deps.ai.model in agent-step.ts. Verify: cd middleware && bun test tests/uc-mw-control-hardening.test.ts.
  6. Gates + report + Done comment. python3 docs/use-cases/_check.py, python3 scripts/check-ai-headers.py, python3 scripts/check-type-mirror.py, cd middleware && bun test, cd agent && go test ./... && gofmt -l agent, rg -n 'Showing lines' …. Then /home/eduardez/workspace/gotoi-wt/meta/issue-68.report.md and the Done comment carrying the full findings list.

Open questions and the assumption I proceed on

  • Does the task mean "fix Critical/High" or "fix everything exploitable"? → Proceeding on Critical/High with a cheap fix; Medium/Low listed for triage. The issue body says Critical/High fixed, the rest listed.
  • Is the unquoted ${VAR} expansion a bug or the feature? → The feature is capture → reuse. The child env already carries every capture (envOf), so shell-side expansion is behaviour-preserving for every existing test and closes the injection. If a plan relied on text-level substitution inside a quoted string, it still works.
  • Change the HMAC payload to cover steps? → No. Both sides plus both suites must move together and a stale agent would refuse every job; the safer short-term control is the agent-side signature/lease/kill gating, which is listed for triage.
  • Is /agents/enrollments returning a raw enroll token to a viewer a leak? → Deliberate, pinned by tests/uc-mw-144-enroll-owner.test.ts. Listed as an accepted design, not a finding.

Risk and fallback

  • Risk: reordering neededRole changes another route's role. Mitigation: move only the /plans/ai/config GET line; deliberately not hoisting /profiles or /groups, whose GET is currently viewer.
  • Risk: the log serializer swallows a field an operator reads. Fallback: keep method, url, host, remoteAddress; drop nothing else.
  • Risk: the transform-free run: change alters a plan's behaviour. Fallback: if any Go test outside the touched file fails, restore substitution and instead quote the substituted value; if that also fails, revert F5 to triage with the test kept red and say so.
  • Risk: a gate outside the diff is already red. Fallback: leave it red, name it in the report, do not silence it.
# Issue #68 — security review of `middleware/` and `agent/` (authn/authz, HMAC, secrets, SSRF) ## Restated goal Audit the six scope areas (authn/authz, agent path-token auth, HMAC job signing, secrets, SSRF/injection, dashboard proxy) and **fix the Critical/High findings on this branch with a red-first regression test each**; every other finding is listed for triage in the Done comment. "Done" for the operator = a smaller verified diff of the exploitable defects, plus one reviewable findings list covering all six areas, including the negative results (threats checked and disproved). Severity is reported honestly; an overstated severity is itself a finding. ## Scope **Changed here (Critical/High, each with a failing-then-passing test):** | # | Finding | File | |---|---|---| | F1 | `GET /plans/ai/config` instance-operator gate is dead code (blanket `GET/HEAD → viewer` line precedes it) | `middleware/src/app.ts` | | F2 | `POST /agent/:id/:token/result` accepts a result for **any** execution (no agent-ownership / org check) | `middleware/src/routes/agent-api.ts` | | F3 | `GET /agent/:id/:token/archive/:planId` serves **any** org's repo plan (no org check) | `middleware/src/routes/agent-api.ts` | | F4 | Agent path token written verbatim into the request log (`logger: true`, no `serializers`) | `middleware/src/app.ts`, `middleware/src/ops.ts` | | F5 | Captured workflow value is substituted unquoted into `sh -c` → command injection on every agent host | `agent/workflow/workflow.go` | | F6 | `find -fprintf/-fprint/-fprint0/-fls` classify as read-only → bypasses plan mode | `middleware/src/command-risk.ts` | | F7 | `proposedBy: turn.model` — undefined identifier (runtime `ReferenceError`) | `middleware/src/agent-step.ts` | **Not touched:** `plans/` (product content), `dashboard/` (proxy `upstreamUrl` strips leading slashes and `SvelteKit` already normalises `..` on the server side; no exploitable difference found — recorded as a negative result), the HMAC payload contract (`plan-artifact.ts` ↔ `agent/verify/verify.go`; changing it is a wire-contract break, listed for triage), `AgentCapabilities` zod tightening (Medium, listed), any new dependency, abstraction or config knob. ## UC decision | Fix | UC | Action | |---|---|---| | F1 | `UC-MW-167` (ai-config) | bump `version:`, correct the Story/Notes so the claimed instance gate is true | | F2, F3 | **new `UC-MW-170`** — "Agent route ownership and org scoping" | new file `docs/use-cases/middleware/UC-MW-170-agent-route-ownership.md`, Implementation cites `middleware/src/routes/agent-api.ts` | | F4 | `UC-OP-09` (token redaction in logs) | bump `version:`, Notes: the helper is now wired into the Fastify logger; Implementation cites `middleware/src/ops.ts` | | F5 | `UC-AG-64` (workflow v2) | bump `version:`, Story: capture values reach `${VAR}` through the child environment, never through string substitution | | F6 | `UC-MW-147` (command policy) | bump `version:`, Notes: the `find` mutate set | | F7 | `UC-MW-166` (agent block) | bump `version:`, Notes: the model id comes from the AI dependency, not a turn object | Test-file rule: the branch changes `middleware/tests/*` and `agent/workflow/workflow_test.go`, and each change ships with the UC edit above in the same branch (satisfies `check-test-tampering.py` `test-change-without-uc`; no assertion is removed, no new skip). ## Ordered tasks (each with its single verification command) 1. **F1 — plan + Plan comment.** Write this file, post it, then add an auth-on case to `middleware/tests/uc-mw-167-ai-config.test.ts`: build with `auth: AUTH`, `createMember` + `setActiveOrg`, `GET /plans/ai/config` → expect `403`. Watch it fail (`200`), then move the `GET /plans/ai/config` rule above the blanket `GET/HEAD` line in `neededRole`. Verify: `cd middleware && bun test tests/uc-mw-167-ai-config.test.ts && python3 docs/use-cases/_check.py`. 2. **F2 + F3 — agent route ownership.** Add `middleware/tests/uc-mw-170-agent-route-ownership.test.ts`: (a) two enrollments; agent A claims a job and reports it, agent B POSTs a result for A's execution → expect `403` and A's execution unchanged in `built.stores.executions`; (b) repo plan owned by org A, agent enrolled under org B requesting `archive` → expect `404`. Add the two guards in `agent-api.ts` (`exec.agentId && exec.agentId !== id` → 403; plan org/shared check → 404). Verify: `cd middleware && bun test tests/uc-mw-170-agent-route-ownership.test.ts && bun test`. 3. **F4 — token redaction in logs.** Export the path rewriter next to `redactToken` in `ops.ts`, wire it as the pino `req` serializer in `buildApp`; widen `BuildAppOptions.logger` to `boolean | FastifyLoggerOptions`. Test in `uc-ops.test.ts`: unit case on the helper, plus an app built with `logger: { stream: { write } }` that polls a job and asserts no captured line contains the token. Verify: `cd middleware && bun test tests/uc-ops.test.ts tests/uc-mw-http.test.ts`. 4. **F5 — workflow injection.** Red-first test in `agent/workflow/workflow_test.go`: step a captures the string `$(touch pwned)`, step b runs `echo ${X}`; assert `pwned` was not created. Fix = drop the manual `${VAR}` substitution for `run:` and let the child environment (already built by `envOf`) do it. Verify: `cd agent && go test ./... && gofmt -l agent`. 5. **F6 + F7 — cheap hardening.** `FIND_MUTATE` additions + a `find … -fprintf` case in `uc-mw-control-hardening.test.ts`; `turn.model` → `deps.ai.model` in `agent-step.ts`. Verify: `cd middleware && bun test tests/uc-mw-control-hardening.test.ts`. 6. **Gates + report + Done comment.** `python3 docs/use-cases/_check.py`, `python3 scripts/check-ai-headers.py`, `python3 scripts/check-type-mirror.py`, `cd middleware && bun test`, `cd agent && go test ./... && gofmt -l agent`, `rg -n 'Showing lines' …`. Then `/home/eduardez/workspace/gotoi-wt/meta/issue-68.report.md` and the Done comment carrying the full findings list. ## Open questions and the assumption I proceed on - *Does the task mean "fix Critical/High" or "fix everything exploitable"?* → Proceeding on Critical/High with a cheap fix; Medium/Low listed for triage. The issue body says Critical/High fixed, the rest listed. - *Is the unquoted `${VAR}` expansion a bug or the feature?* → The feature is capture → reuse. The child env already carries every capture (`envOf`), so shell-side expansion is behaviour-preserving for every existing test and closes the injection. If a plan relied on text-level substitution inside a quoted string, it still works. - *Change the HMAC payload to cover `steps`?* → No. Both sides plus both suites must move together and a stale agent would refuse every job; the safer short-term control is the agent-side signature/lease/kill gating, which is listed for triage. - *Is `/agents/enrollments` returning a raw enroll token to a `viewer` a leak?* → Deliberate, pinned by `tests/uc-mw-144-enroll-owner.test.ts`. Listed as an accepted design, not a finding. ## Risk and fallback - **Risk:** reordering `neededRole` changes another route's role. **Mitigation:** move only the `/plans/ai/config` GET line; deliberately *not* hoisting `/profiles` or `/groups`, whose GET is currently `viewer`. - **Risk:** the log serializer swallows a field an operator reads. **Fallback:** keep `method`, `url`, `host`, `remoteAddress`; drop nothing else. - **Risk:** the `transform`-free `run:` change alters a plan's behaviour. **Fallback:** if any Go test outside the touched file fails, restore substitution and instead quote the substituted value; if that also fails, revert F5 to triage with the test kept red and say so. - **Risk:** a gate outside the diff is already red. **Fallback:** leave it red, name it in the report, do not silence it.
Author
Collaborator

Done — security review of middleware/ + agent/ (PARTIAL)

Outcome: PARTIAL. One High finding fixed with a red-first regression test and committed. The other
Critical/High findings from the Plan comment are not fixed — they are listed below with severity,
file:line evidence, and the exact fix. Honest PARTIAL over a false DONE.

Refs #68. Branch issue/68-security-review, base origin/agent-develop @ b64f46a, HEAD 37bb159.
Not pushed (task law).

Commit

Hash Subject
37bb159 middleware: gate GET /plans/ai/config to instance operators

Files: middleware/src/app.ts (hoist), middleware/tests/uc-mw-167-ai-config.test.ts (new
UC-MW-167 instance-operator gate block), docs/use-cases/middleware/UC-MW-167-ai-config.md
(version: → 2026-10-08, Notes record the ordering requirement).
Red-first: new assertion failed Expected: 403 / Received: 200 before the hoist; 7 pass / 0 fail after.

Gates

python3 docs/use-cases/_check.py            → exit 0  ("All use cases conform to the guidelines.")
python3 scripts/check-ai-headers.py         → exit 0  (573 ok, 0 missing, 0 malformed)
python3 scripts/check-type-mirror.py        → exit 0
cd middleware && bun test tests/uc-mw-167-ai-config.test.ts
                                            → 7 pass / 0 fail, 1 non-failing error (see note)
rg -n 'Showing lines' --glob '!node_modules' --glob '!.git'
                                            → 9 hits, all pre-existing prose in AGENTS.md / RELEASING_INSTRUCTIONS.md / docs/*plan*.md; no source file

Not run: full bun test (minutes; time-box) and cd agent && go test ./... / gofmt -l agent (no agent
file changed).

Pre-existing bun test noise, not introduced here. Any 403 from the onRequest access hook makes
Fastify print Error: Cannot writeHead headers after they are sent to the client
(light-my-request/lib/response.js:96 → fastify/lib/error-handler.js:35-40), which bun test counts
toward its exit code. The HTTP status is correct (403) and every assertion passes. Reproduced on
pristine HEAD with the F1 pair stashed, asserting POST /profiles as a member → 403 (1 error) —
so it predates this branch. uc-mw-tenancy-isolation.test.ts flaked once under load (PGlite 5s timeout)
and passed on rerun.

FIXED

F1 — HIGH — dead instance-operator gate on GET /plans/ai/config.
middleware/src/app.ts neededRole: the instance rule for GET /plans/ai/config sat after
if (m === 'GET' || m === 'HEAD') return 'viewer';, so it was unreachable. Any authenticated org member
could read the stored AI provider config, whose headers may carry a private provider token
(aiConfigView, middleware/src/routes/plans.ts:404-417, returns headers verbatim). Fix: hoisted the
rule above the blanket GET line. Pinned by the new test block.

NOT FIXED — Critical/High (fix specified; each needs its own red-first test)

F2 — HIGH — POST /agent/:id/:token/result accepts a result for any execution.
middleware/src/routes/agent-api.ts:281-298: after safeEqual(agent.token, token) the handler does
stores.executions.get(parsed.data.executionId) with no exec.agentId !== id and no org check;
applyResult (middleware/src/executions.ts:41-68) takes no agent arg. A valid token for agent B can
post a fabricated remediated/fixed_verified result for agent A's execution, mutating
plan.verifiedStreak via recordOutcome (:301), tripping circuit.recordFailure/recordSuccess keyed
plan:/host: (:316-324, no org component), and firing enqueueRollback / scheduleRecheck /
proposePlanEdit. Contrast POST /agent/:id/:token/executions/:executionId/start (:268-279), which
correctly refuses a mismatch via startExecution (executions.ts:31). Fix in the handler; do not
change applyResult's signature.

F3 — HIGH — GET /agent/:id/:token/archive/:planId serves any org's repo plan.
middleware/src/routes/agent-api.ts:185-200: checks only that the plan exists, is format === 'repo',
and has a commit. Plan ids are guessable slugs; no organizationId comparison. Fix:
if (plan.organizationId !== agent.organizationId && !isSharedPlan(plan.id)) return reply.code(404)…
(isSharedPlan is exported from middleware/src/types.ts:337).

F2+F3 were paired into a planned new UC-MW-170 "Agent route ownership and org scoping" plus
middleware/tests/uc-mw-170-agent-route-ownership.test.ts; the time-box ended before that pair landed.

F4 — HIGH — agent path token written verbatim into the request log.
middleware/src/server.ts:73 logger: true; middleware/src/app.ts:247
Fastify({ logger: opts.logger ?? false, forceCloseConnections: true }) has no serializers, so
Fastify 5's default req serializer emits url = /agent/<uuid>/<token>/job. redactToken
(middleware/src/ops.ts:42-46) has zero production callers. Fix = a pino req serializer wired through
Fastify({ logger: { serializers: { req } } }) (FastifyLoggerOptions is exported from fastify),
plus widening BuildAppOptions.logger (app.ts:127) so a test can capture lines.

F5 — HIGH (Go) — captured workflow value substituted unquoted into sh -c.
agent/workflow/workflow.go:392 exec.CommandContext(stepCtx, "sh", "-c", expand(step.Run, vars));
expand (:513-522) does raw ${VAR} text substitution (varRe :131). cmd.Env (:400-401) already
exports every capture, so dropping the substitution for the run: branch is behaviour-preserving for
TestRunCaptureSubstitution and TestRunScriptSeesCaptureEnv. Red-first: step a captures
$(touch pwned), step b runs echo ${X}, assert pwned absent.

F6 — MEDIUM — FIND_MUTATE omissions.
middleware/src/command-risk.ts:21 new Set(['-delete','-exec','-execdir','-ok']) omits
-fprint/-fprintf/-fls/-fprint0 → find … -fprintf /etc/cron.d/x classifies read-only and bypasses plan
mode. middleware/tests/uc-mw-control-hardening.test.ts:48 only covers find / -delete.

F7 — latent TS/ReferenceError — proposedBy: turn.model.
middleware/src/agent-step.ts:200 uses turn.model; turn is not in scope in that file (other arms use
deps.ai.model, e.g. :237). runAgentStep has no production caller (only
middleware/tests/uc-mw-166-agent-block.test.ts:314, which exits at the disabled early return :86).
Fix = deps.ai.model.

LISTED FOR TRIAGE (Medium/Low or contract-breaking)

  • middleware/src/sources.ts:41 if (typeof value === 'string') continue; allows a raw plaintext value
    for a sensitive: true field; routes/sources.ts:159 stores it in alert_sources.config jsonb.
    Redacted on read (redactSource, sources.ts:81-101) → no HTTP leak; mergeSourceConfig
    (sources.ts:107-122) preserves it. A refs-only rule changes the dashboard forms
    (alerts/sources/new/+page.svelte:68,81,117,286, alerts/sources/+page.svelte:71,80,391) — contract change.
  • aiConfigView (routes/plans.ts:404-417) returns headers verbatim and the PATCH arm (:445) accepts
    any header name → Authorization: Bearer sk-… round-trips (operator-only, Low).
  • POST /agent/:id/:token/capabilities (agent-api.ts:212-219) accepts any non-null object, no
    .strict(); storeCapabilities (agents.ts:116-124) assigns wholesale. Wrong-typed commands throws
    in selectAgent (capabilities.ts:45-47) → /job self-DoS; an agent can also inflate
    caps.capabilities.length + caps.commands.length (coordinator.ts:70-71) to win claims. No prototype
    pollution, no XSS.
  • GET …/executions/:id/status (:175-182) and GET …/diagnose (:202-209, hostname-only filter) share
    the F2/F3 missing org gate. archive returns 403 on a bad token where every other agent route returns
    401 (backwards, cosmetic).
  • middleware/src/agent-releases.ts:132 embeds /agent/${agent.id}/${agent.token}/update.bin?… in the
    durable heartbeat payload.
  • No rate limiting on any /agent/:id/:token/* route (only webhook ingest + source exec, app.ts:682-683).
    Token entropy is fine: crypto.randomUUID() (122 bits); safeEqual (ops.ts:105-107) is a correct
    hash-then-timingSafeEqual.
  • HMAC payload wedge. verify.Fingerprint (agent/verify/verify.go:87-89) covers only
    planId|version|commit|executionId|issuedAt, mirrored exactly by jobFingerprint
    (plan-artifact.ts:37-39). steps/host/runtime.entrypoint/requirements are outside the MAC, and
    agent/main.go:469/488/512 gate verify + kill-switch + lease behind job.PlanVersion != "". Changing
    Fingerprint is a middleware↔agent wire break (both sides + both suites must co-change; a stale agent
    would refuse every job) — listed, not patched.
  • Repo plans bypass policy.RefuseEscalation: agent/workflow/workflow.go does not import agent/policy;
    agent/main.go:521 refuseDisallowedCommands runs before the job.PlanRepo branch at :527 and is a
    no-op unless plan.requirements.commands is non-empty — and requirements is not settable by any route.
    agent/orchestrator runs runtime.entrypoint with no safeJoin/policy, but neither planCreateBody
    nor planPatchBody (both .strict()) can set it.
  • GET /agents/enrollments returning a raw enroll token to a viewer is deliberate, pinned by
    tests/uc-mw-144-enroll-owner.test.ts:60-96. Accepted design.

NEGATIVE RESULTS (checked, disproved — do not re-investigate)

  • No HTTP handler returns a decrypted secret; encryptSecret/decryptSecret AES-256-GCM sound
    (secrets.ts:20-27,30,46,59); audit log carries no secret values.
  • SQL fully parameterized; the only raw SQL is db/migrate.ts:85 (fixed name) and routes/meta.ts:27
    (constant select 1).
  • LLM tool args cannot bypass command-policy (control-agent.ts gates before enqueue);
    shellQuote/wrapShell (control-agent.ts:106-114) correct.
  • agent/pi has no file-write path and validates LLM plan ids. Invite tokens randomBytes(24).base64url.
    Git creds via GIT_CONFIG_VALUE_0 env, never argv/URL (git.ts:91-93). artifactPath rejects ../absolute.
  • Job claiming is org-scoped (coordinator.ts:25,89); POST /executions/:id/start is owner-checked;
    agent token at rest is 0600; the SSE hub strips token + secret.
  • SSRF: providers/common.ts blocks non-http(s), metadata*, 169.254.0.0/16, fe80::/10, IPv4-mapped
    forms, resolves DNS before fetch; fetchBounded uses redirect: 'manual'.
  • Dashboard proxy: proxy.ts upstreamUrl strips leading slashes, SvelteKit normalises .. before the
    handler — no exploitable difference. No change made.

Full write-up: /home/eduardez/workspace/gotoi-wt/meta/issue-68.report.md.
Remaining Critical/High fixes are specified with their verification command in
/home/eduardez/workspace/gotoi-wt/meta/issue-68.plan.md (F2–F7).

## Done — security review of `middleware/` + `agent/` (PARTIAL) Outcome: **PARTIAL**. One High finding fixed with a red-first regression test and committed. The other Critical/High findings from the Plan comment are **not** fixed — they are listed below with severity, `file:line` evidence, and the exact fix. Honest PARTIAL over a false DONE. Refs #68. Branch `issue/68-security-review`, base `origin/agent-develop` @ `b64f46a`, HEAD `37bb159`. Not pushed (task law). ### Commit | Hash | Subject | |---|---| | `37bb159` | `middleware: gate GET /plans/ai/config to instance operators` | Files: `middleware/src/app.ts` (hoist), `middleware/tests/uc-mw-167-ai-config.test.ts` (new `UC-MW-167 instance-operator gate` block), `docs/use-cases/middleware/UC-MW-167-ai-config.md` (`version:` → 2026-10-08, Notes record the ordering requirement). Red-first: new assertion failed `Expected: 403 / Received: 200` before the hoist; `7 pass / 0 fail` after. ### Gates ``` python3 docs/use-cases/_check.py → exit 0 ("All use cases conform to the guidelines.") python3 scripts/check-ai-headers.py → exit 0 (573 ok, 0 missing, 0 malformed) python3 scripts/check-type-mirror.py → exit 0 cd middleware && bun test tests/uc-mw-167-ai-config.test.ts → 7 pass / 0 fail, 1 non-failing error (see note) rg -n 'Showing lines' --glob '!node_modules' --glob '!.git' → 9 hits, all pre-existing prose in AGENTS.md / RELEASING_INSTRUCTIONS.md / docs/*plan*.md; no source file ``` Not run: full `bun test` (minutes; time-box) and `cd agent && go test ./...` / `gofmt -l agent` (no agent file changed). **Pre-existing `bun test` noise, not introduced here.** Any 403 from the `onRequest` access hook makes Fastify print `Error: Cannot writeHead headers after they are sent to the client` (`light-my-request/lib/response.js:96` → `fastify/lib/error-handler.js:35-40`), which `bun test` counts toward its exit code. The HTTP status is correct (`403`) and every assertion passes. Reproduced on **pristine HEAD** with the F1 pair stashed, asserting `POST /profiles` as a member → 403 (`1 error`) — so it predates this branch. `uc-mw-tenancy-isolation.test.ts` flaked once under load (PGlite 5s timeout) and passed on rerun. ### FIXED **F1 — HIGH — dead instance-operator gate on `GET /plans/ai/config`.** `middleware/src/app.ts` `neededRole`: the instance rule for `GET /plans/ai/config` sat *after* `if (m === 'GET' || m === 'HEAD') return 'viewer';`, so it was unreachable. Any authenticated org member could read the stored AI provider config, whose `headers` may carry a private provider token (`aiConfigView`, `middleware/src/routes/plans.ts:404-417`, returns `headers` verbatim). Fix: hoisted the rule above the blanket GET line. Pinned by the new test block. ### NOT FIXED — Critical/High (fix specified; each needs its own red-first test) **F2 — HIGH — `POST /agent/:id/:token/result` accepts a result for any execution.** `middleware/src/routes/agent-api.ts:281-298`: after `safeEqual(agent.token, token)` the handler does `stores.executions.get(parsed.data.executionId)` with no `exec.agentId !== id` and no org check; `applyResult` (`middleware/src/executions.ts:41-68`) takes no agent arg. A valid token for agent B can post a fabricated `remediated`/`fixed_verified` result for agent A's execution, mutating `plan.verifiedStreak` via `recordOutcome` (`:301`), tripping `circuit.recordFailure/recordSuccess` keyed `plan:`/`host:` (`:316-324`, no org component), and firing `enqueueRollback` / `scheduleRecheck` / `proposePlanEdit`. Contrast `POST /agent/:id/:token/executions/:executionId/start` (`:268-279`), which correctly refuses a mismatch via `startExecution` (`executions.ts:31`). Fix in the handler; do **not** change `applyResult`'s signature. **F3 — HIGH — `GET /agent/:id/:token/archive/:planId` serves any org's repo plan.** `middleware/src/routes/agent-api.ts:185-200`: checks only that the plan exists, is `format === 'repo'`, and has a `commit`. Plan ids are guessable slugs; no `organizationId` comparison. Fix: `if (plan.organizationId !== agent.organizationId && !isSharedPlan(plan.id)) return reply.code(404)…` (`isSharedPlan` is exported from `middleware/src/types.ts:337`). F2+F3 were paired into a planned new `UC-MW-170` "Agent route ownership and org scoping" plus `middleware/tests/uc-mw-170-agent-route-ownership.test.ts`; the time-box ended before that pair landed. **F4 — HIGH — agent path token written verbatim into the request log.** `middleware/src/server.ts:73` `logger: true`; `middleware/src/app.ts:247` `Fastify({ logger: opts.logger ?? false, forceCloseConnections: true })` has no `serializers`, so Fastify 5's default `req` serializer emits `url` = `/agent/<uuid>/<token>/job`. `redactToken` (`middleware/src/ops.ts:42-46`) has zero production callers. Fix = a pino `req` serializer wired through `Fastify({ logger: { serializers: { req } } })` (`FastifyLoggerOptions` is exported from `fastify`), plus widening `BuildAppOptions.logger` (`app.ts:127`) so a test can capture lines. **F5 — HIGH (Go) — captured workflow value substituted unquoted into `sh -c`.** `agent/workflow/workflow.go:392` `exec.CommandContext(stepCtx, "sh", "-c", expand(step.Run, vars))`; `expand` (`:513-522`) does raw `${VAR}` text substitution (`varRe` `:131`). `cmd.Env` (`:400-401`) already exports every capture, so dropping the substitution for the `run:` branch is behaviour-preserving for `TestRunCaptureSubstitution` and `TestRunScriptSeesCaptureEnv`. Red-first: step a captures `$(touch pwned)`, step b runs `echo ${X}`, assert `pwned` absent. **F6 — MEDIUM — `FIND_MUTATE` omissions.** `middleware/src/command-risk.ts:21` `new Set(['-delete','-exec','-execdir','-ok'])` omits `-fprint/-fprintf/-fls/-fprint0` → `find … -fprintf /etc/cron.d/x` classifies read-only and bypasses plan mode. `middleware/tests/uc-mw-control-hardening.test.ts:48` only covers `find / -delete`. **F7 — latent TS/ReferenceError — `proposedBy: turn.model`.** `middleware/src/agent-step.ts:200` uses `turn.model`; `turn` is not in scope in that file (other arms use `deps.ai.model`, e.g. `:237`). `runAgentStep` has no production caller (only `middleware/tests/uc-mw-166-agent-block.test.ts:314`, which exits at the `disabled` early return `:86`). Fix = `deps.ai.model`. ### LISTED FOR TRIAGE (Medium/Low or contract-breaking) - `middleware/src/sources.ts:41` `if (typeof value === 'string') continue;` allows a raw plaintext value for a `sensitive: true` field; `routes/sources.ts:159` stores it in `alert_sources.config` jsonb. Redacted on read (`redactSource`, `sources.ts:81-101`) → no HTTP leak; `mergeSourceConfig` (`sources.ts:107-122`) preserves it. A refs-only rule changes the dashboard forms (`alerts/sources/new/+page.svelte:68,81,117,286`, `alerts/sources/+page.svelte:71,80,391`) — contract change. - `aiConfigView` (`routes/plans.ts:404-417`) returns `headers` verbatim and the PATCH arm (`:445`) accepts any header name → `Authorization: Bearer sk-…` round-trips (operator-only, Low). - `POST /agent/:id/:token/capabilities` (`agent-api.ts:212-219`) accepts any non-null object, no `.strict()`; `storeCapabilities` (`agents.ts:116-124`) assigns wholesale. Wrong-typed `commands` throws in `selectAgent` (`capabilities.ts:45-47`) → `/job` self-DoS; an agent can also inflate `caps.capabilities.length + caps.commands.length` (`coordinator.ts:70-71`) to win claims. No prototype pollution, no XSS. - `GET …/executions/:id/status` (`:175-182`) and `GET …/diagnose` (`:202-209`, hostname-only filter) share the F2/F3 missing org gate. `archive` returns 403 on a bad token where every other agent route returns 401 (backwards, cosmetic). - `middleware/src/agent-releases.ts:132` embeds `/agent/${agent.id}/${agent.token}/update.bin?…` in the durable heartbeat payload. - No rate limiting on any `/agent/:id/:token/*` route (only webhook ingest + source exec, `app.ts:682-683`). Token entropy is fine: `crypto.randomUUID()` (122 bits); `safeEqual` (`ops.ts:105-107`) is a correct hash-then-`timingSafeEqual`. - **HMAC payload wedge.** `verify.Fingerprint` (`agent/verify/verify.go:87-89`) covers only `planId|version|commit|executionId|issuedAt`, mirrored exactly by `jobFingerprint` (`plan-artifact.ts:37-39`). `steps`/`host`/`runtime.entrypoint`/`requirements` are outside the MAC, and `agent/main.go:469/488/512` gate verify + kill-switch + lease behind `job.PlanVersion != ""`. Changing `Fingerprint` is a middleware↔agent wire break (both sides + both suites must co-change; a stale agent would refuse every job) — listed, not patched. - Repo plans bypass `policy.RefuseEscalation`: `agent/workflow/workflow.go` does not import `agent/policy`; `agent/main.go:521` `refuseDisallowedCommands` runs before the `job.PlanRepo` branch at `:527` and is a no-op unless `plan.requirements.commands` is non-empty — and `requirements` is not settable by any route. `agent/orchestrator` runs `runtime.entrypoint` with no `safeJoin`/policy, but neither `planCreateBody` nor `planPatchBody` (both `.strict()`) can set it. - `GET /agents/enrollments` returning a raw enroll token to a `viewer` is deliberate, pinned by `tests/uc-mw-144-enroll-owner.test.ts:60-96`. Accepted design. ### NEGATIVE RESULTS (checked, disproved — do not re-investigate) - No HTTP handler returns a decrypted secret; `encryptSecret`/`decryptSecret` AES-256-GCM sound (`secrets.ts:20-27,30,46,59`); audit log carries no secret values. - SQL fully parameterized; the only raw SQL is `db/migrate.ts:85` (fixed name) and `routes/meta.ts:27` (constant `select 1`). - LLM tool args cannot bypass `command-policy` (`control-agent.ts` gates before enqueue); `shellQuote`/`wrapShell` (`control-agent.ts:106-114`) correct. - `agent/pi` has no file-write path and validates LLM plan ids. Invite tokens `randomBytes(24).base64url`. Git creds via `GIT_CONFIG_VALUE_0` env, never argv/URL (`git.ts:91-93`). `artifactPath` rejects `..`/absolute. - Job claiming **is** org-scoped (`coordinator.ts:25,89`); `POST /executions/:id/start` **is** owner-checked; agent token at rest is 0600; the SSE hub strips token + secret. - SSRF: `providers/common.ts` blocks non-http(s), `metadata*`, `169.254.0.0/16`, `fe80::/10`, IPv4-mapped forms, resolves DNS before fetch; `fetchBounded` uses `redirect: 'manual'`. - Dashboard proxy: `proxy.ts` `upstreamUrl` strips leading slashes, SvelteKit normalises `..` before the handler — no exploitable difference. No change made. Full write-up: `/home/eduardez/workspace/gotoi-wt/meta/issue-68.report.md`. Remaining Critical/High fixes are specified with their verification command in `/home/eduardez/workspace/gotoi-wt/meta/issue-68.plan.md` (F2–F7).
Author
Collaborator

REVIEW: PARTIAL — findings: F1 gate on GET /plans/ai/config verified correct in 37bb159 (admin 200 / member 403, gate precedes blanket GET rule, 7/0 tests, all 4 checkers green); implementer report honesty verified — every gate claim re-produced. UNFIXED: F2 HIGH result route has no agent-ownership check (middleware/src/routes/agent-api.ts:291 — guard must be exec.agentId && exec.agentId !== id else UC-MW-97 unclaimed-result test breaks); F3 HIGH archive route never compares plan.organizationId to agent.organizationId (agent-api.ts:185 — fix: org mismatch && !isSharedPlan -> 404); F4 HIGH Fastify logs agent URL with path token verbatim (server.ts:73 logger:true, app.ts:247 no serializers; redactToken in ops.ts:43 has zero prod callers — fix: pino req serializer); F5 HIGH workflow run: string-substitutes captured vars into sh -c (agent/workflow/workflow.go:392 expand() — fix: bare step.Run, envOf already exports vars); F6 MED FIND_MUTATE misses -fprint/-fprintf/-fls/-fprint0 (command-risk.ts:21); F7 latent turn.model out of scope (agent-step.ts:200); triage: status/diagnose agent routes lack org checks. No reviewer commits — stopped before edits; working tree clean.

REVIEW: PARTIAL — findings: F1 gate on GET /plans/ai/config verified correct in 37bb159 (admin 200 / member 403, gate precedes blanket GET rule, 7/0 tests, all 4 checkers green); implementer report honesty verified — every gate claim re-produced. UNFIXED: F2 HIGH result route has no agent-ownership check (middleware/src/routes/agent-api.ts:291 — guard must be `exec.agentId && exec.agentId !== id` else UC-MW-97 unclaimed-result test breaks); F3 HIGH archive route never compares plan.organizationId to agent.organizationId (agent-api.ts:185 — fix: org mismatch && !isSharedPlan -> 404); F4 HIGH Fastify logs agent URL with path token verbatim (server.ts:73 logger:true, app.ts:247 no serializers; redactToken in ops.ts:43 has zero prod callers — fix: pino req serializer); F5 HIGH workflow run: string-substitutes captured vars into sh -c (agent/workflow/workflow.go:392 expand() — fix: bare step.Run, envOf already exports vars); F6 MED FIND_MUTATE misses -fprint/-fprintf/-fls/-fprint0 (command-risk.ts:21); F7 latent turn.model out of scope (agent-step.ts:200); triage: status/diagnose agent routes lack org checks. No reviewer commits — stopped before edits; working tree clean.
Author
Collaborator

Done (partial by design — see thread). Branch issue/68-security-review (1 commit: 37bb159), PR: #75 (targets agent-develop).

Fixed: F1 HIGH — dead instance-operator gate on GET /plans/ai/config (37bb159, red-first test in UC-MW-167).

Unfixed Highs documented with fix specs: F2 result-route ownership, F3 archive cross-org, F4 token in request logs, F5 workflow sh -c substitution. F6/F7 + triage list in the findings comment. Negative results list included.

Gates: docs/use-cases/_check.py exit 0 (358 files); check-ai-headers 573 ok; check-type-mirror exit 0; middleware bun test 585 run — 584 pass, 1 fail = UC-MW-09 flake class (re-passed isolated twice, PGlite-under-load pattern, not this diff); dashboard/agent gates n/a (no diff). review: PARTIAL.

Needs operator decision: fix F2-F5 on follow-up issues, or land this audit branch as-is. Do NOT merge while Highs F2-F4 remain open unless accepted.

Done (partial by design — see thread). Branch `issue/68-security-review` (1 commit: 37bb159), PR: https://git.eduard3v.com/EduarD3V/gotoi/pulls/75 (targets agent-develop). Fixed: F1 HIGH — dead instance-operator gate on GET /plans/ai/config (37bb159, red-first test in UC-MW-167). Unfixed Highs documented with fix specs: F2 result-route ownership, F3 archive cross-org, F4 token in request logs, F5 workflow sh -c substitution. F6/F7 + triage list in the findings comment. Negative results list included. Gates: docs/use-cases/_check.py exit 0 (358 files); check-ai-headers 573 ok; check-type-mirror exit 0; middleware bun test 585 run — 584 pass, 1 fail = UC-MW-09 flake class (re-passed isolated twice, PGlite-under-load pattern, not this diff); dashboard/agent gates n/a (no diff). review: PARTIAL. Needs operator decision: fix F2-F5 on follow-up issues, or land this audit branch as-is. Do NOT merge while Highs F2-F4 remain open unless accepted.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
EduarD3V/gotoi#68
No description provided.