Problem
buildAgentCommandInput in src/gateway/openai-http.ts hardcodes owner authority:
// src/gateway/openai-http.ts:123
senderIsOwner: true as const, // "HTTP API callers are authenticated operator clients for this gateway context."
That justification does not hold under a config-reachable posture. The gateway supports an auth: "none" mode (src/gateway/auth-resolve.ts:9 — ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"). On an auth:"none" gateway, an unauthenticated caller hits /v1/chat/completions and is handed senderIsOwner: true, which enables owner-only MCP tools downstream.
This is an owner-authorization (IDOR-class) gap on a KEPT control, in the same class as the recently-fixed chat.abort owner-authz (#2721) and the shared-secret-rotation WS disconnect (#2722).
Impact (privilege scope, not cosmetic)
senderIsOwner propagates to owner-only tool registration:
src/gateway/openai-http.ts:123 (senderIsOwner: true as const)
- →
src/middleware/channel-bridge.ts:374 (REMOTECLAW_SENDER_IS_OWNER env)
- →
src/middleware/mcp-server.ts:25
- →
src/middleware/mcp-tools.ts:98 (if (ctx.senderIsOwner) gates owner-only tool registration)
So an unauthenticated caller on an auth:"none" gateway is granted owner-level tool authority (nodes/canvas/cron etc.).
Root cause (verified)
The shared endpoint helper discards the auth result. handleGatewayPostJsonEndpoint (src/gateway/http-endpoint-helpers.ts:33) computes authorized at line 35 (authorizeGatewayBearerRequestOrReply) but its return type is false | { body: unknown } | undefined — the boolean and the satisfying auth method never escape the helper. The OpenAI-compat path (and the sibling openresponses-http.ts:280, tools-invoke-http.ts) therefore cannot distinguish a shared-secret-bearer caller from an unauthenticated one.
The fork has none of the HTTP-auth→owner derivation machinery (grep-confirmed zero hits for resolveHttpSenderIsOwner / resolveOpenAiCompatibleHttpSenderIsOwner / usesSharedSecretGatewayMethod / AuthorizedGatewayHttpRequest in src/). The fork does have ADMIN_SCOPE and method-scopes.ts, but no HTTP-auth→owner bridge.
A skipped test already encodes the desired behavior and an accurate diagnosis: src/gateway/openai-http.test.ts:628 (it.skip), with context at :614-627. The shared-secret-bearer→owner arm is already covered by the passing test "treats shared-secret bearer callers as owner operators" (referenced near openai-http.test.ts:626-627), so the machinery has a partial anchor.
What to build
- Surface the auth result from the shared helper. Change
handleGatewayPostJsonEndpoint (http-endpoint-helpers.ts:33, :55-70) to return the satisfying auth context, e.g. { body: unknown; auth: { method: "shared-secret" | "bearer" | "trusted-proxy" | "none"; senderIsOwner: boolean } }. Derive senderIsOwner from the auth method:
- shared-secret-bearer → owner
- ADMIN-scope bearer → owner
auth:"none" / unauthenticated → NOT owner
Keep the introduced type minimal and fork-local (a 2-field result object suffices — do not re-import a large upstream AuthorizedGatewayHttpRequest shape if not needed; src/gateway/method-scopes.ts:1 already provides ADMIN_SCOPE + scope helpers to key on).
- Thread it into
buildAgentCommandInput. Add a senderIsOwner: boolean param (openai-http.ts:107-125) and replace the line-123 hardcode with the passed value; populate it from the helper's returned auth at the call site (openai-http.ts:476).
- Apply consistently to the sibling endpoints that share the helper and likely carry the same hardcode posture:
src/gateway/openresponses-http.ts:280 and src/gateway/tools-invoke-http.ts:135. Audit each for an equivalent owner assumption and fix in the same PR.
The helper still rejects unauthorized callers exactly as today — this change only ADDS the auth context to the success return; it must not regress the existing allow/deny path.
Test plan
Un-skip src/gateway/openai-http.test.ts:628 (it.skip → it). It already asserts the core invariant: unauthenticated caller on an auth:"none" server → firstCall?.senderIsOwner === false (line 643). Add companion cases in the same describe:
- shared-secret bearer →
senderIsOwner === true (assert explicitly against buildAgentCommandInput output).
- ADMIN-scope bearer (token mode) →
senderIsOwner === true.
auth:"token" with a WRONG/absent bearer → request rejected (401) before reaching agentCommand (guards that the helper's deny path is untouched by the return-type change).
- Add equivalent
senderIsOwner===false-on-auth:"none" assertions for openresponses-http and tools-invoke-http if the same hardcode is found there.
Gate the PR on the full test-gateway lane — green without admin-bypass or weakened gates.
Severity & risk
- Severity: HIGH. Owner-level tool authority granted to unauthenticated callers on any
auth:"none" gateway (privilege escalation / IDOR-class on a KEPT control). Bounded by deployment posture (operators who set auth:"none" and expose the port), but auth:"none" is documented and reachable, and src/security/audit-gateway-config.ts already treats loopback-without-proxy as a finding — consistent with treating this as real.
- Fix risk: Medium. Touches a SHARED helper used by ≥3 endpoints; the return-type change is mechanical but the behavior flip (an
auth:"none" caller currently treated as owner becomes non-owner) is the intended delta and must be called out prominently in the PR description.
Why a dedicated PR (not folded into the #2724 cleanups)
This flips behavior with a real (if bounded) blast radius across three HTTP endpoints — a HIGH-severity hardening change that warrants its own focused review, exactly as the it.skip comment in openai-http.test.ts already prescribes. Split out from the gateway-suite remediation tracker #2724 (itself from #2720, "enable the gateway behavioral test suite in CI").
DIFF-SYNC note
When implemented, add an in-code comment tying the owner-derivation to the threat it addresses, and a guard note so a future upstream sync does not re-interpret the restored derivation as an "upstream feature to drop." This issue is self-contained; no external context is required.
Problem
buildAgentCommandInputinsrc/gateway/openai-http.tshardcodes owner authority:That justification does not hold under a config-reachable posture. The gateway supports an
auth: "none"mode (src/gateway/auth-resolve.ts:9—ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"). On anauth:"none"gateway, an unauthenticated caller hits/v1/chat/completionsand is handedsenderIsOwner: true, which enables owner-only MCP tools downstream.This is an owner-authorization (IDOR-class) gap on a KEPT control, in the same class as the recently-fixed
chat.abortowner-authz (#2721) and the shared-secret-rotation WS disconnect (#2722).Impact (privilege scope, not cosmetic)
senderIsOwnerpropagates to owner-only tool registration:src/gateway/openai-http.ts:123(senderIsOwner: true as const)src/middleware/channel-bridge.ts:374(REMOTECLAW_SENDER_IS_OWNERenv)src/middleware/mcp-server.ts:25src/middleware/mcp-tools.ts:98(if (ctx.senderIsOwner)gates owner-only tool registration)So an unauthenticated caller on an
auth:"none"gateway is granted owner-level tool authority (nodes/canvas/cron etc.).Root cause (verified)
The shared endpoint helper discards the auth result.
handleGatewayPostJsonEndpoint(src/gateway/http-endpoint-helpers.ts:33) computesauthorizedat line 35 (authorizeGatewayBearerRequestOrReply) but its return type isfalse | { body: unknown } | undefined— the boolean and the satisfying auth method never escape the helper. The OpenAI-compat path (and the siblingopenresponses-http.ts:280,tools-invoke-http.ts) therefore cannot distinguish a shared-secret-bearer caller from an unauthenticated one.The fork has none of the HTTP-auth→owner derivation machinery (grep-confirmed zero hits for
resolveHttpSenderIsOwner/resolveOpenAiCompatibleHttpSenderIsOwner/usesSharedSecretGatewayMethod/AuthorizedGatewayHttpRequestinsrc/). The fork does haveADMIN_SCOPEandmethod-scopes.ts, but no HTTP-auth→owner bridge.A skipped test already encodes the desired behavior and an accurate diagnosis:
src/gateway/openai-http.test.ts:628(it.skip), with context at:614-627. The shared-secret-bearer→owner arm is already covered by the passing test"treats shared-secret bearer callers as owner operators"(referenced nearopenai-http.test.ts:626-627), so the machinery has a partial anchor.What to build
handleGatewayPostJsonEndpoint(http-endpoint-helpers.ts:33,:55-70) to return the satisfying auth context, e.g.{ body: unknown; auth: { method: "shared-secret" | "bearer" | "trusted-proxy" | "none"; senderIsOwner: boolean } }. DerivesenderIsOwnerfrom the auth method:auth:"none"/ unauthenticated → NOT ownerKeep the introduced type minimal and fork-local (a 2-field result object suffices — do not re-import a large upstream
AuthorizedGatewayHttpRequestshape if not needed;src/gateway/method-scopes.ts:1already providesADMIN_SCOPE+ scope helpers to key on).buildAgentCommandInput. Add asenderIsOwner: booleanparam (openai-http.ts:107-125) and replace the line-123 hardcode with the passed value; populate it from the helper's returned auth at the call site (openai-http.ts:476).src/gateway/openresponses-http.ts:280andsrc/gateway/tools-invoke-http.ts:135. Audit each for an equivalent owner assumption and fix in the same PR.The helper still rejects unauthorized callers exactly as today — this change only ADDS the auth context to the success return; it must not regress the existing allow/deny path.
Test plan
Un-skip
src/gateway/openai-http.test.ts:628(it.skip→it). It already asserts the core invariant: unauthenticated caller on anauth:"none"server →firstCall?.senderIsOwner === false(line 643). Add companion cases in the same describe:senderIsOwner === true(assert explicitly againstbuildAgentCommandInputoutput).senderIsOwner === true.auth:"token"with a WRONG/absent bearer → request rejected (401) before reachingagentCommand(guards that the helper's deny path is untouched by the return-type change).senderIsOwner===false-on-auth:"none"assertions foropenresponses-httpandtools-invoke-httpif the same hardcode is found there.Gate the PR on the full
test-gatewaylane — green without admin-bypass or weakened gates.Severity & risk
auth:"none"gateway (privilege escalation / IDOR-class on a KEPT control). Bounded by deployment posture (operators who setauth:"none"and expose the port), butauth:"none"is documented and reachable, andsrc/security/audit-gateway-config.tsalready treats loopback-without-proxy as a finding — consistent with treating this as real.auth:"none"caller currently treated as owner becomes non-owner) is the intended delta and must be called out prominently in the PR description.Why a dedicated PR (not folded into the #2724 cleanups)
This flips behavior with a real (if bounded) blast radius across three HTTP endpoints — a HIGH-severity hardening change that warrants its own focused review, exactly as the
it.skipcomment inopenai-http.test.tsalready prescribes. Split out from the gateway-suite remediation tracker #2724 (itself from #2720, "enable the gateway behavioral test suite in CI").DIFF-SYNC note
When implemented, add an in-code comment tying the owner-derivation to the threat it addresses, and a guard note so a future upstream sync does not re-interpret the restored derivation as an "upstream feature to drop." This issue is self-contained; no external context is required.