Skip to content

Derive senderIsOwner from auth on the OpenAI-compatible HTTP endpoint (owner authority granted to unauthenticated callers under auth:"none") #2735

Description

@alexey-pelykh

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:9ResolvedGatewayAuthMode = "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

  1. 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).
  2. 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).
  3. 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.skipit). 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions