Files
alkhttp/docs/architecture/decisions/071-dispatch-schema-guard.md
T
glm-5.3-flash 207bca4f23 fix(gateway): block services/schema spec disclosure via the op path (review-002 PRJ-16)
Internal/ACL-restricted op specs were readable through
POST /call {"operation":"services/schema","input":{"name":...}}
(the MCP call/batch tools identically): the outer-name pre-checks pass
(services/schema is External) and alkcall's services_schema_handler
projects any registered spec with no visibility/ACL check of its own
(alkcall CF-004 is the complete fix there).

- GatewayDispatch.invoke/invoke_streaming now apply the GET /schema
  route's is-internal + access-control checks to the meta-op's inner
  name input before dispatch (404 Internal / FORBIDDEN ACL), one
  interception point covering /call, /batch, /subscribe and the MCP
  call/batch tools; /publish cannot reach the Query-typed meta-op
- the visibility+ACL check is one shared fn (schema_disclosure_denial)
  used by the HTTP /schema route, the dispatch guard, and the MCP
  schema tool, so transports cannot drift
- when CF-004 lands, this guard remains as defense-in-depth (ADR-071)

Tests: dispatch-spine guard unit tests; /call 404 + 401/403 matrix,
/batch NOT_FOUND entry, /subscribe error event; MCP call/batch tools
via services/schema with an Internal inner name (mcp feature).

Verify: cargo test (405), --all-features (523), clippy default and
--all-features --all-targets -D warnings, fmt --check — all pass.
2026-08-31 01:32:59 +00:00

3.3 KiB

ADR-071: The dispatch-spine services/schema op-path guard

Status

Accepted

Context

Review-002 PRJ-16 ([major, security]) found that the GET /schema disclosure fixes (GW-02, SRV-02) had a sibling hole through the op path: services/schema is an External operation with default ACL, so the gateway's outer-name pre-checks admit it — and alkcall's services_schema_handler performs a bare registry.registration(name) → spec projection with no visibility and no AccessControl check of its own. Any HTTP caller could fetch the full spec (input/output/error schemas, required_scopes) of an Internal or ACL-restricted operation with:

POST /call {"operation":"services/schema","input":{"name":"secret/op"}}

— unauthenticated, and identically through the MCP call and batch tools. The same GET /schema route that returns 404 for that op was thus circumventable in one request. (Disclosure only; the target op's handler never executes.)

The complete fix is alkcall-side: the handler itself must check — filed as CF-004 in alkcall/docs/reviews/consumer-findings-ledger.md. But the alkcall lead time was unknown, and the hole is in this crate's wire surface today.

Decision

GatewayDispatch blocks services/schema invocations whose inner name input would be denied by the GET /schema route for the same identity. Before dispatch, when the resolved operation is the services/schema meta-op, the spine applies the same two checks the route applies to ?name=:

  • Inner op is Visibility::InternalNOT_FOUND (404 on HTTP).
  • Inner op denies the caller's AccessControlFORBIDDEN (401 unauthenticated / 403 authenticated under the gateway error mapping — identical to the GET /schema denials).

The guard lives in the dispatch spine (invoke + invoke_streaming), not in the route handlers, because every HTTP transport and the MCP call/batch tools flow through it — one interception point covers /call, /batch, /subscribe (defense-in-depth: the registry rejects the Query-typed meta-op on the streaming path anyway), and the MCP tools. Sink dispatch (/publish) cannot reach services/schema: the registry rejects non-Pub operations before the handler runs and the sink ignores the input, so nothing is projected there.

The visibility + ACL check itself is one shared function (gateway::dispatch::schema_disclosure_denial) used by the HTTP GET /schema route, the dispatch-spine guard, and the MCP schema tool, so the transports cannot drift.

Consequences

  • Transport-level invariant (PRJ-16's wording): no alkhttp transport can fetch, through any path, a spec the GET /schema route would deny for the same identity.
  • When CF-004 lands, the guard stays. The alkcall-side handler check is the complete fix for every transport (wire, overlays, peer composition); this per-transport check remains as defense-in-depth. Do not remove it.
  • Wire-observable behavior changes only for the previously-leaking requests: they now get the same 404/403 the GET route returns. Legitimate services/schema calls (allowed inner names) are unaffected.
  • The guard matches the outer registration's spec.name against the services/schema constant rather than the raw request string, so leading-slash variants (/services/schema) hit the same check.