From f29efde73a802bfca4a9bdbf038a00857c4927e2 Mon Sep 17 00:00:00 2001 From: Ava Daigo Date: Sun, 3 May 2026 23:46:58 +0000 Subject: [PATCH] fix(gateway): scoped no-auth local backend bypass (#75781) When gateway.auth.mode is 'none', the local backend self-pairing skip was gated on sharedAuthOk, which stays false for no-auth mode. The missing-device handler still rejected with 1008: device identity required. Fix: shouldSkipLocalBackendSelfPairing now bypasses sharedAuthOk entirely when authMethod is 'none' and the connection is local (direct_local or shared_secret_loopback_local) without browser origin. Remote and browser-originated connections still require proper device auth. ClawSweeper P1: Make the none-auth backend bypass reachable ClawSweeper P2: Test the reachable none-auth connect state Co-Authored-By: Paperclip --- CHANGELOG.md | 1 + .../ws-connection/auth-context.state.test.ts | 25 ++++++++ .../handshake-auth-helpers.test.ts | 58 +++++++++++++++++++ .../ws-connection/handshake-auth-helpers.ts | 16 +++-- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e69400247..883e16fe948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1204,6 +1204,7 @@ Docs: https://docs.openclaw.ai - Agents/replies: defer implicit image model discovery and keep OAuth auth-store adoption on persisted profiles during reply startup, cutting OCM MarCodex warm prep to sub-second in live checks. Thanks @shakkernerd. - Plugins/tools: enforce `contracts.tools` as the manifest ownership contract for plugin tool registration, rejecting undeclared runtime tool names and adding bundled plugin drift coverage. Thanks @shakkernerd. - Agents/Codex: stop prompting message-tool-only source turns to finish with `NO_REPLY`, so quiet turns are represented by not calling the visible message tool instead of conflicting final-text instructions. Thanks @pashpashpash. + - Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme. - Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer. - Gateway/config: allow `gateway config.patch` to update documented subagent thinking defaults. Fixes #75764. (#75802) Thanks @kAIborg24. diff --git a/src/gateway/server/ws-connection/auth-context.state.test.ts b/src/gateway/server/ws-connection/auth-context.state.test.ts index a4b65355b9f..e94e1e5c852 100644 --- a/src/gateway/server/ws-connection/auth-context.state.test.ts +++ b/src/gateway/server/ws-connection/auth-context.state.test.ts @@ -78,6 +78,31 @@ describe("resolveConnectAuthState", () => { }); describe("resolveConnectAuthDecision", () => { + it("sets sharedAuthOk false when auth mode is none (no shared secret provided)", async () => { + const state = await resolveConnectAuthState({ + resolvedAuth: { + mode: "none", + } satisfies ResolvedGatewayAuth, + connectAuth: {}, + hasDeviceIdentity: false, + req: { + headers: {}, + socket: { remoteAddress: "127.0.0.1" }, + } as never, + trustedProxies: [], + allowRealIpFallback: false, + rateLimiter: createLimiter(), + clientIp: "127.0.0.1", + }); + + expect(state.authOk).toBe(true); + expect(state.authMethod).toBe("none"); + // auth:none does NOT set sharedAuthOk globally — it's not a shared secret. + // Only shouldSkipLocalBackendSelfPairing treats auth:none as shared-auth-scoped + // for local backend connections specifically. + expect(state.sharedAuthOk).toBe(false); + }); + it("resets the shared-secret limiter after device-token auth succeeds", async () => { const rateLimiter = createLimiter(); await resolveConnectAuthDecision({ diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index d288bbbda08..5f379b8d381 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -451,6 +451,64 @@ describe("handshake auth helpers", () => { ).toBe(false); }); + it("skips backend self-pairing when auth mode is none (scoped, sharedAuthOk-independent)", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + } as ConnectParams; + // auth:none on local backend skips regardless of sharedAuthOk + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "direct_local", + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "none", + }), + ).toBe(true); + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "shared_secret_loopback_local", + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "none", + }), + ).toBe(true); + // sharedAuthOk=false is fine for auth:none on local backend + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "direct_local", + hasBrowserOriginHeader: false, + sharedAuthOk: false, + authMethod: "none", + }), + ).toBe(true); + // Remote connections with auth:none should NOT skip + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "remote", + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "none", + }), + ).toBe(false); + // Browser origin with auth:none should NOT skip + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "direct_local", + hasBrowserOriginHeader: true, + sharedAuthOk: false, + authMethod: "none", + }), + ).toBe(false); + }); + it("classifies non-CLI loopback + shared-secret clients as shared_secret_loopback_local", () => { const connectParams = { client: { diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 3a0c0cced5a..da2a561aea6 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -262,13 +262,19 @@ export function shouldSkipLocalBackendSelfPairing(params: { if (!isBackendClient) { return false; } + const isLocal = + params.locality === "direct_local" || params.locality === "shared_secret_loopback_local"; + if (!isLocal || params.hasBrowserOriginHeader) { + return false; + } + // No-auth local backend: scoped bypass — not shared secret, but local-only + // device-less operation is safe when auth.mode is explicitly "none". + if (params.authMethod === "none") { + return true; + } const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; const usesDeviceTokenAuth = params.authMethod === "device-token"; - return ( - (params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") && - !params.hasBrowserOriginHeader && - ((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth) - ); + return (params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth; } function resolveSignatureToken(connectParams: ConnectParams): string | null {