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.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# 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::Internal` → `NOT_FOUND` (404 on HTTP).
|
||||
- Inner op denies the caller's `AccessControl` → `FORBIDDEN`
|
||||
(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.
|
||||
Reference in New Issue
Block a user