From 89fc92c3ca5ea5489706b95399f44c149231c467 Mon Sep 17 00:00:00 2001 From: rick Date: Tue, 3 Mar 2026 20:39:01 -0600 Subject: [PATCH 1/5] fix(ui): request full operator scopes for gateway client --- ui/src/ui/gateway.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 5d0c4e73f2f..e12db30bd78 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -173,7 +173,13 @@ export class GatewayBrowserClient { // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; - const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const scopes = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + ]; const role = "operator"; let deviceIdentity: Awaited> | null = null; let canFallbackToShared = false; From 1cab57f25770317defd995db920bb3938ba70f3b Mon Sep 17 00:00:00 2001 From: rick Date: Tue, 3 Mar 2026 21:03:21 -0600 Subject: [PATCH 2/5] fix(gateway): allow local shared-secret auth in trusted-proxy mode --- src/gateway/auth.test.ts | 22 ++++++++++++++ src/gateway/auth.ts | 63 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 07d90d2d134..974e2a2074d 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -395,6 +395,28 @@ describe("trusted-proxy auth", () => { expect(res.user).toBe("nick@example.com"); }); + it("allows local-direct shared-token auth in trusted-proxy mode", async () => { + const localToken = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + token: "secret", + trustedProxy: trustedProxyConfig, + }, + connectAuth: { token: "secret" }, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "127.0.0.1:19001", + }, + } as never, + }); + + expect(localToken.ok).toBe(true); + expect(localToken.method).toBe("token"); + }); + it("rejects request from untrusted source", async () => { const res = await authorizeTrustedProxy({ remoteAddress: "192.168.1.100", diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 6315a899e76..b5f9e885ad9 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -75,6 +75,14 @@ export type AuthorizeGatewayConnectParams = { allowRealIpFallback?: boolean; }; +type SharedSecretAuthParams = { + auth: ResolvedGatewayAuth; + connectAuth?: ConnectAuth | null; + limiter?: AuthRateLimiter; + ip?: string; + rateLimitScope: string; +}; + type TailscaleUser = { login: string; name: string; @@ -361,6 +369,30 @@ function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolea return authSurface === "ws-control-ui"; } +function authorizeSharedSecretFallback(params: SharedSecretAuthParams): GatewayAuthResult | null { + const { auth, connectAuth, limiter, ip, rateLimitScope } = params; + + if (auth.password && connectAuth?.password) { + if (!safeEqualSecret(connectAuth.password, auth.password)) { + limiter?.recordFailure(ip, rateLimitScope); + return { ok: false, reason: "password_mismatch" }; + } + limiter?.reset(ip, rateLimitScope); + return { ok: true, method: "password" }; + } + + if (auth.token && connectAuth?.token) { + if (!safeEqualSecret(connectAuth.token, auth.token)) { + limiter?.recordFailure(ip, rateLimitScope); + return { ok: false, reason: "token_mismatch" }; + } + limiter?.reset(ip, rateLimitScope); + return { ok: true, method: "token" }; + } + + return null; +} + export async function authorizeGatewayConnect( params: AuthorizeGatewayConnectParams, ): Promise { @@ -373,8 +405,33 @@ export async function authorizeGatewayConnect( trustedProxies, params.allowRealIpFallback === true, ); + const limiter = params.rateLimiter; + const ip = + params.clientIp ?? + resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ?? + req?.socket?.remoteAddress; + const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET; + const localLoopbackWithoutProxyHeaders = + Boolean(req) && + isLoopbackAddress(req?.socket?.remoteAddress) && + !req?.headers?.["x-forwarded-for"] && + !req?.headers?.["x-real-ip"] && + !req?.headers?.["x-forwarded-host"]; if (auth.mode === "trusted-proxy") { + if (localDirect || localLoopbackWithoutProxyHeaders) { + const sharedSecretFallback = authorizeSharedSecretFallback({ + auth, + connectAuth, + limiter, + ip, + rateLimitScope, + }); + if (sharedSecretFallback) { + return sharedSecretFallback; + } + } + if (!auth.trustedProxy) { return { ok: false, reason: "trusted_proxy_config_missing" }; } @@ -398,12 +455,6 @@ export async function authorizeGatewayConnect( return { ok: true, method: "none" }; } - const limiter = params.rateLimiter; - const ip = - params.clientIp ?? - resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ?? - req?.socket?.remoteAddress; - const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET; if (limiter) { const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); if (!rlCheck.allowed) { From 8dfb38af22a9bda26ffc2310de88c2f841312467 Mon Sep 17 00:00:00 2001 From: rick Date: Tue, 3 Mar 2026 23:28:04 -0600 Subject: [PATCH 3/5] fix(gateway): tighten trusted-proxy local fallback --- src/gateway/auth.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++ src/gateway/auth.ts | 14 +++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 974e2a2074d..4012b6990c3 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -417,6 +417,63 @@ describe("trusted-proxy auth", () => { expect(localToken.method).toBe("token"); }); + it("does not let local fallback preempt valid trusted-proxy header auth", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + token: "secret", + trustedProxy: trustedProxyConfig, + }, + connectAuth: { token: "wrong" }, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("trusted-proxy"); + expect(res.user).toBe("nick@example.com"); + }); + + it("applies rate limiting before trusted-proxy local shared-secret fallback", async () => { + const limiter = createLimiterSpy(); + limiter.check.mockReturnValue({ + allowed: false, + remaining: 0, + retryAfterMs: 60_000, + }); + + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + token: "secret", + trustedProxy: trustedProxyConfig, + }, + connectAuth: { token: "secret" }, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "127.0.0.1:19001", + }, + } as never, + rateLimiter: limiter, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("rate_limited"); + expect(limiter.check).toHaveBeenCalled(); + }); + it("rejects request from untrusted source", async () => { const res = await authorizeTrustedProxy({ remoteAddress: "192.168.1.100", diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index b5f9e885ad9..de82ee3a33e 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -419,7 +419,19 @@ export async function authorizeGatewayConnect( !req?.headers?.["x-forwarded-host"]; if (auth.mode === "trusted-proxy") { - if (localDirect || localLoopbackWithoutProxyHeaders) { + if (localLoopbackWithoutProxyHeaders && limiter) { + const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); + if (!rlCheck.allowed) { + return { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: rlCheck.retryAfterMs, + }; + } + } + + if (localLoopbackWithoutProxyHeaders) { const sharedSecretFallback = authorizeSharedSecretFallback({ auth, connectAuth, From 33e388c6916e43a5f8e26a17ca774631d12284da Mon Sep 17 00:00:00 2001 From: rick Date: Wed, 4 Mar 2026 00:13:18 -0600 Subject: [PATCH 4/5] fix(gateway): honor configured trusted-proxy headers in local fallback --- src/gateway/auth.test.ts | 1 - src/gateway/auth.ts | 22 +++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 4012b6990c3..0dfb232ffaa 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -431,7 +431,6 @@ describe("trusted-proxy auth", () => { socket: { remoteAddress: "127.0.0.1" }, headers: { host: "gateway.local", - "x-forwarded-for": "203.0.113.10", "x-forwarded-user": "nick@example.com", "x-forwarded-proto": "https", }, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index de82ee3a33e..cdfd4519f40 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -393,6 +393,24 @@ function authorizeSharedSecretFallback(params: SharedSecretAuthParams): GatewayA return null; } +function hasConfiguredTrustedProxyHeaders( + req: IncomingMessage | undefined, + trustedProxyConfig: GatewayTrustedProxyConfig | undefined, +): boolean { + if (!req || !trustedProxyConfig) { + return false; + } + + const headers = [trustedProxyConfig.userHeader, ...(trustedProxyConfig.requiredHeaders ?? [])] + .map((header) => header?.trim().toLowerCase()) + .filter((header): header is string => Boolean(header)); + + return headers.some((header) => { + const value = headerValue(req.headers[header]); + return typeof value === "string" && value.trim() !== ""; + }); +} + export async function authorizeGatewayConnect( params: AuthorizeGatewayConnectParams, ): Promise { @@ -414,9 +432,7 @@ export async function authorizeGatewayConnect( const localLoopbackWithoutProxyHeaders = Boolean(req) && isLoopbackAddress(req?.socket?.remoteAddress) && - !req?.headers?.["x-forwarded-for"] && - !req?.headers?.["x-real-ip"] && - !req?.headers?.["x-forwarded-host"]; + !hasConfiguredTrustedProxyHeaders(req, auth.trustedProxy); if (auth.mode === "trusted-proxy") { if (localLoopbackWithoutProxyHeaders && limiter) { From c9dfc5407a798c1c8dd44e821ca83322828b383b Mon Sep 17 00:00:00 2001 From: rick Date: Tue, 3 Mar 2026 21:03:21 -0600 Subject: [PATCH 5/5] fix(gateway): allow local shared-secret auth in trusted-proxy mode --- src/gateway/auth.test.ts | 19 ------------------- src/gateway/auth.ts | 7 ++----- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 1b10ffea479..0dfb232ffaa 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -138,25 +138,6 @@ describe("gateway auth", () => { }); }); - it("treats env-template auth secrets as SecretRefs instead of plaintext", () => { - expect( - resolveGatewayAuth({ - authConfig: { - token: "${OPENCLAW_GATEWAY_TOKEN}", - password: "${OPENCLAW_GATEWAY_PASSWORD}", - }, - env: { - OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", - } as NodeJS.ProcessEnv, - }), - ).toMatchObject({ - token: "env-token", - password: "env-password", - mode: "password", - }); - }); - it("resolves explicit auth mode none from config", () => { expect( resolveGatewayAuth({ diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index feb65a18590..cdfd4519f40 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -4,7 +4,6 @@ import type { GatewayTailscaleMode, GatewayTrustedProxyConfig, } from "../config/config.js"; -import { resolveSecretInputRef } from "../config/types.secrets.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { @@ -252,11 +251,9 @@ export function resolveGatewayAuth(params: { } } const env = params.env ?? process.env; - const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref; - const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref; const resolvedCredentials = resolveGatewayCredentialsFromValues({ - configToken: tokenRef ? undefined : authConfig.token, - configPassword: passwordRef ? undefined : authConfig.password, + configToken: authConfig.token, + configPassword: authConfig.password, env, includeLegacyEnv: false, tokenPrecedence: "config-first",