fix(gateway): derive senderIsOwner from gateway auth on OpenAI-compat HTTP (#2735)#2746
Merged
Merged
Conversation
… 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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
buildAgentCommandInput(openai-http.ts) andrunResponsesAgentCommand(
openresponses-http.ts) hardcodedsenderIsOwner: true, granting owner-levelMCP tool authority to UNAUTHENTICATED callers on an
auth:"none"gateway — anIDOR-class authorization gap. Owner authority is now derived from the gateway
auth method that satisfied the connect check.
Closes #2735
Resulting derivation
senderIsOwnertoken/password(shared-secret bearer)none/trusted-proxy+ explicitx-remoteclaw-scopes: operator.adminnone/trusted-proxy+ nox-remoteclaw-scopesheadernone/trusted-proxy+ non-admin declared scopesA header-less
auth:"none"chat call still works (200, not 403): thechat.sendmethod-scope gate stays permissive — the fix downscopes owner, itdoes not reject the caller.
Upstream OpenClaw, for a trusted (non-shared-secret) request that sends no
x-*-scopesheader, returnsCLI_DEFAULT_OPERATOR_SCOPES(which includesoperator.admin) — i.e. upstream intentionally grants owner to a header-lesscaller 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 thenon-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_SCOPESbranch — doing so silently re-opensthis IDOR. The divergence is guarded three ways (parallel to the #2733
re-introduction guard; ADR-0011):
FORK-INTRODUCED SECURITY DIVERGENCE (#2735)comment inhttp-utils.tsdocumenting the upstream posture and why the fork differs.http-utils.owner-derivation.test.ts) pinning theno-header → []branch and the gate-vs-owner split.scripts/check-openai-http-owner-derivation.mjs+ agateway meta-test
owner-derivation-guard.test.ts) that fails if theliteral unauthenticated-no-header
senderIsOwner:falseanchor test isremoved, 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 consolidatedlayout —
resolveTrustedHttpOperatorScopes(the divergence),resolveHttpSenderIsOwner, and the OpenAI-compat resolvers(
resolveOpenAiCompatibleHttpOperatorScopes= method-scope gate, stayspermissive on no-header;
resolveOpenAiCompatibleHttpSenderIsOwner= owner,strict).
usesSharedSecretGatewayMethoddistinguishes the bearer short-circuit.http-auth-helpers.ts: newauthorizeGatewayHttpRequestOrReplysurfacesthe satisfying auth method (
AuthorizedGatewayHttpRequest) so owner-derivationcan run; the boolean
authorizeGatewayBearerRequestOrReplyis retained fortools-invoke-http(allow/deny only).http-endpoint-helpers.ts: threadsrequestAuthback to callers andaccepts a
resolveOperatorScopesoverride for the compat scope model.openai-http.ts/openresponses-http.ts: derivesenderIsOwnerfromrequestAuthand thread it into the agent command (no more hardcodedtrue).tools-invoke-http.tsis unchanged.server.plugin-gateway-auth-tripwire.test.ts):re-pointed the JSON-endpoint chokepoint row to the new
authorizeGatewayHttpRequestOrReplyand added a row for the bearerchokepoint's remaining
tools-invoke-httpcall site (count 4 → 5);strengthened the rate-limiter-threading check to require every chokepoint
wrapper forward the limiter.
Validation
(
vitest run --config vitest.gateway.config.ts --pool=forks).pnpm check(oxfmt + tsgo + oxlint + custom lints) green.(zero
@ts-expect-error), throwing-stub-callers.xit/it.only/renamed-anchor / flipped-assertion mutations — all correctly fail the gate.
src/middleware/changes, so the LIVE smoke lane is not required.🤖 Generated with Claude Code