Skip to content

fix(gateway): derive senderIsOwner from gateway auth on OpenAI-compat HTTP (#2735)#2746

Merged
alexey-pelykh merged 1 commit into
mainfrom
fix/gateway-openai-http-sender-is-owner-2735
Jun 18, 2026
Merged

fix(gateway): derive senderIsOwner from gateway auth on OpenAI-compat HTTP (#2735)#2746
alexey-pelykh merged 1 commit into
mainfrom
fix/gateway-openai-http-sender-is-owner-2735

Conversation

@alexey-pelykh

Copy link
Copy Markdown

Summary

buildAgentCommandInput (openai-http.ts) and runResponsesAgentCommand
(openresponses-http.ts) hardcoded senderIsOwner: true, granting owner-level
MCP tool authority to UNAUTHENTICATED callers
on an auth:"none" gateway — an
IDOR-class authorization gap. Owner authority is now derived from the gateway
auth method that satisfied the connect check.

Closes #2735

Resulting derivation

Auth on the OpenAI-compat surface senderIsOwner
token / password (shared-secret bearer) true (short-circuit, before consulting declared scopes)
none / trusted-proxy + explicit x-remoteclaw-scopes: operator.admin true
none / trusted-proxy + no x-remoteclaw-scopes header false ⬅ the fix
none / trusted-proxy + non-admin declared scopes false

A header-less auth:"none" chat call still works (200, not 403): the
chat.send method-scope gate stays permissive — the fix downscopes owner, it
does not reject the caller.

⚠️ This owner-derivation is a FORK-INTRODUCED divergence — do NOT "sync back"

Upstream OpenClaw, for a trusted (non-shared-secret) request that sends no
x-*-scopes header, returns CLI_DEFAULT_OPERATOR_SCOPES (which includes
operator.admin) — i.e. upstream intentionally grants owner to a header-less
caller on auth:"none" (matching pre-openclaw#57783 "trusted local CLI" behavior).

RemoteClaw runs gateways network-exposed over messaging channels, where
auth:"none" + no-header is the realistic attacker, not a trusted local CLI.
The fork therefore deliberately returns [] (→ NOT owner) on the
non-shared-secret no-header path, in resolveTrustedHttpOperatorScopes
(http-utils.ts).

This is a deliberate hardening divergence, NOT a restored upstream control.
A future DIFF-SYNC MUST NOT re-adopt upstream's
no-header → CLI_DEFAULT_OPERATOR_SCOPES branch — doing so silently re-opens
this IDOR. The divergence is guarded three ways (parallel to the #2733
re-introduction guard; ADR-0011):

  1. A function-level FORK-INTRODUCED SECURITY DIVERGENCE (#2735) comment in
    http-utils.ts documenting the upstream posture and why the fork differs.
  2. A unit table (http-utils.owner-derivation.test.ts) pinning the
    no-header → [] branch and the gate-vs-owner split.
  3. A CI-assert guard (scripts/check-openai-http-owner-derivation.mjs + a
    gateway meta-test owner-derivation-guard.test.ts) that fails if the
    literal unauthenticated-no-header senderIsOwner:false anchor test is
    removed, skipped, or stops asserting not-owner — so green CI stays a faithful
    proxy for "the hole is closed".

Changes

  • http-utils.ts: ported trust-gate machinery into the fork's consolidated
    layout — resolveTrustedHttpOperatorScopes (the divergence),
    resolveHttpSenderIsOwner, and the OpenAI-compat resolvers
    (resolveOpenAiCompatibleHttpOperatorScopes = method-scope gate, stays
    permissive on no-header; resolveOpenAiCompatibleHttpSenderIsOwner = owner,
    strict). usesSharedSecretGatewayMethod distinguishes the bearer short-circuit.
  • http-auth-helpers.ts: new authorizeGatewayHttpRequestOrReply surfaces
    the satisfying auth method (AuthorizedGatewayHttpRequest) so owner-derivation
    can run; the boolean authorizeGatewayBearerRequestOrReply is retained for
    tools-invoke-http (allow/deny only).
  • http-endpoint-helpers.ts: threads requestAuth back to callers and
    accepts a resolveOperatorScopes override for the compat scope model.
  • openai-http.ts / openresponses-http.ts: derive senderIsOwner from
    requestAuth and thread it into the agent command (no more hardcoded true).
    tools-invoke-http.ts is unchanged.
  • gateway test-suite remediation (#2720): deferred cleanups, parity gaps, and skip-restorations #2724 auth-wiring tripwire (server.plugin-gateway-auth-tripwire.test.ts):
    re-pointed the JSON-endpoint chokepoint row to the new
    authorizeGatewayHttpRequestOrReply and added a row for the bearer
    chokepoint's remaining tools-invoke-http call site (count 4 → 5);
    strengthened the rate-limiter-threading check to require every chokepoint
    wrapper forward the limiter.

Validation

  • Full gateway lane green: 146 files, 1605 passed, 6 skipped, 0 failed
    (vitest run --config vitest.gateway.config.ts --pool=forks).
  • pnpm check (oxfmt + tsgo + oxlint + custom lints) green.
  • Fork-integrity gates green: rebrand, zombie-import, stub-debt
    (zero @ts-expect-error), throwing-stub-callers.
  • The CI-assert guard was negative-tested against skip / xit / it.only /
    renamed-anchor / flipped-assertion mutations — all correctly fail the gate.
  • No src/middleware/ changes, so the LIVE smoke lane is not required.

Do NOT merge from here. Per the orchestration plan, the orchestrator
verifies the no-header hole is closed + the literal-no-header test + the
CI-assert guard, then merges.

🤖 Generated with Claude Code

… HTTP (#2735)

`buildAgentCommandInput` (openai-http.ts) and `runResponsesAgentCommand`
(openresponses-http.ts) hardcoded `senderIsOwner: true`, granting owner-level
MCP tool authority to UNAUTHENTICATED callers on an `auth:"none"` gateway — an
IDOR-class authorization gap. Owner authority is now DERIVED from the gateway
auth method that satisfied the connect check.

- Port upstream's trust-gate machinery into the fork's consolidated http-utils
  layout (resolveTrustedHttpOperatorScopes / resolveHttpSenderIsOwner /
  resolveOpenAiCompatible* helpers) with ONE deliberate, FORK-INTRODUCED
  hardening divergence: a non-shared-secret caller (none / trusted-proxy) that
  sends NO `x-remoteclaw-scopes` header resolves to `[]` (NOT owner), whereas
  upstream returns `CLI_DEFAULT_OPERATOR_SCOPES` (which includes
  `operator.admin`). RemoteClaw runs gateways network-exposed over messaging
  channels, so `auth:"none"` + no-header is the realistic attacker, not a
  trusted local CLI.
- token/password (shared-secret) bearer → owner via short-circuit (unchanged
  trusted-operator contract); explicit `operator.admin` declaration → owner;
  header-less or non-admin → NOT owner.
- The `chat.send` method-scope gate stays PERMISSIVE on a missing header so a
  plain OpenAI-SDK chat still works under `auth:"none"` — the fix downscopes
  OWNER, it does NOT 403 the caller (the rejected "B2" behavior).
- New `authorizeGatewayHttpRequestOrReply` surfaces the satisfying auth method
  (vs the boolean `authorizeGatewayBearerRequestOrReply`, retained for
  tools-invoke-http) so owner derivation can run downstream.
- CI-assert guard (scripts/check-openai-http-owner-derivation.mjs + a gateway
  meta-test) fails if the unauthenticated-no-header `senderIsOwner:false` anchor
  test is removed, skipped, or stops asserting not-owner.
- Re-point the #2724 auth-wiring tripwire to the new chokepoint and add a row
  for the bearer chokepoint's remaining tools-invoke call site (count 4→5);
  strengthen the rate-limiter-threading check to require EVERY chokepoint
  wrapper forward the limiter.

This is a FORK-INTRODUCED control, NOT a restored upstream behavior. A future
DIFF-SYNC MUST keep `senderIsOwner` threaded and MUST NOT re-adopt upstream's
no-header → `CLI_DEFAULT_OPERATOR_SCOPES` branch — doing so silently re-opens
the IDOR (parallel to the #2733 re-introduction guard; ADR-0011).

Closes #2735

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@alexey-pelykh
alexey-pelykh merged commit 26697e0 into main Jun 18, 2026
17 checks passed
@alexey-pelykh
alexey-pelykh deleted the fix/gateway-openai-http-sender-is-owner-2735 branch June 18, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant