diff --git a/CHANGELOG.md b/CHANGELOG.md index a21b84abdca..4f2ee55ec12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/auth: make local-direct `trusted-proxy` fallback require the configured shared token instead of silently authenticating same-host callers, while keeping same-host reverse proxy identity-header flows on the normal trusted-proxy path. Thanks @zhangning-agent and @vincentkoc. +- Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman. - LINE/ACP: add current-conversation binding and inbound binding-routing parity so `/acp spawn ... --thread here`, configured ACP bindings, and active conversation-bound ACP sessions work on LINE like the other conversation channels. - LINE/markdown: preserve underscores inside Latin, Cyrillic, and CJK words when stripping markdown, while still removing standalone `_italic_` markers on the shared text-runtime path used by LINE and TTS. (#47465) Thanks @jackjin1997. - TTS/Microsoft: auto-switch the default Edge voice to Chinese for CJK-dominant text without overriding explicitly selected Microsoft voices. (#52355) Thanks @extrasmall0. diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index a07ae6b0832..f3d943a0b61 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -632,4 +632,174 @@ describe("trusted-proxy auth", () => { expect(res.ok).toBe(true); expect(res.user).toBe("nick@example.com"); }); + + describe("local-direct token fallback", () => { + function authorizeLocalDirect(options?: { + token?: string; + connectToken?: string; + trustedProxy?: GatewayConnectInput["auth"]["trustedProxy"]; + trustedProxies?: string[]; + }) { + return authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + ...(Object.hasOwn(options ?? {}, "trustedProxy") + ? { trustedProxy: options?.trustedProxy } + : { trustedProxy: trustedProxyConfig }), + token: options?.token, + }, + connectAuth: options?.connectToken ? { token: options.connectToken } : null, + trustedProxies: options?.trustedProxies ?? ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { host: "localhost" }, + } as never, + }); + } + + it("allows local-direct request with a valid token", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + connectToken: "secret", + }); + expect(res.ok).toBe(true); + expect(res.method).toBe("token"); + }); + + it("rejects local-direct request without credentials", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_missing"); + }); + + it("rejects local-direct request with a wrong token", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + connectToken: "wrong", + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_mismatch"); + }); + + it("rejects local-direct request when no local token is configured", async () => { + const res = await authorizeLocalDirect({ + connectToken: "secret", + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_missing_config"); + }); + + it("rejects trusted-proxy identity headers from loopback sources", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "localhost", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + }, + } as never, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_loopback_source"); + }); + + it("fails closed when forwarded headers are present but the client chain resolves to loopback", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + token: "secret", + }, + connectAuth: null, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "localhost", + "x-forwarded-for": "127.0.0.1", + "x-forwarded-proto": "https", + }, + } as never, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_loopback_source"); + }); + + it("uses token fallback for direct loopback even when Host is not localish", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + token: "secret", + }, + connectAuth: { token: "secret" }, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "evil.example", + }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("token"); + }); + + it("rejects same-host proxy request with missing required header", async () => { + const res = await authorizeGatewayConnect({ + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: trustedProxyConfig, + }, + connectAuth: null, + trustedProxies: ["127.0.0.1"], + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "localhost", + "x-forwarded-user": "nick@example.com", + // missing x-forwarded-proto (requiredHeader) + }, + } as never, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_loopback_source"); + }); + + it("still fails closed when trusted-proxy config is missing", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + connectToken: "secret", + trustedProxy: undefined, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_config_missing"); + }); + + it("still fails closed when trusted proxies are not configured", async () => { + const res = await authorizeLocalDirect({ + token: "secret", + connectToken: "secret", + trustedProxies: [], + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("trusted_proxy_no_proxies_configured"); + }); + }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index a9f98be1c92..51d666c3e48 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -14,7 +14,6 @@ import { } from "./auth-rate-limit.js"; import { resolveGatewayCredentialsFromValues } from "./credentials.js"; import { - isLocalishHost, isLoopbackAddress, resolveRequestClientIp, isTrustedProxyAddress, @@ -115,25 +114,25 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined { export function isLocalDirectRequest( req?: IncomingMessage, - trustedProxies?: string[], - allowRealIpFallback = false, + _trustedProxies?: string[], + _allowRealIpFallback = false, ): boolean { if (!req) { return false; } - const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback) ?? ""; - if (!isLoopbackAddress(clientIp)) { - return false; - } const hasForwarded = Boolean( + req.headers?.forwarded || req.headers?.["x-forwarded-for"] || + req.headers?.["x-forwarded-proto"] || req.headers?.["x-real-ip"] || req.headers?.["x-forwarded-host"], ); - const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies); - return isLocalishHost(req.headers?.host) && (!hasForwarded || remoteIsTrustedProxy); + if (!hasForwarded) { + return isLoopbackAddress(req.socket?.remoteAddress); + } + return false; } function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null { @@ -337,6 +336,9 @@ function authorizeTrustedProxy(params: { if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) { return { reason: "trusted_proxy_untrusted_source" }; } + if (isLoopbackAddress(remoteAddr)) { + return { reason: "trusted_proxy_loopback_source" }; + } const requiredHeaders = trustedProxyConfig.requiredHeaders ?? []; for (const header of requiredHeaders) { @@ -365,6 +367,30 @@ function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolea return authSurface === "ws-control-ui"; } +function authorizeTokenAuth(params: { + authToken?: string; + connectToken?: string; + limiter?: AuthRateLimiter; + ip?: string; + rateLimitScope: string; +}): GatewayAuthResult { + if (!params.authToken) { + return { ok: false, reason: "token_missing_config" }; + } + if (!params.connectToken) { + // Don't burn rate-limit slots for missing credentials — the client + // simply hasn't provided a token yet (e.g. bare browser open). + // Only actual *wrong* credentials should count as failures. + return { ok: false, reason: "token_missing" }; + } + if (!safeEqualSecret(params.connectToken, params.authToken)) { + params.limiter?.recordFailure(params.ip, params.rateLimitScope); + return { ok: false, reason: "token_mismatch" }; + } + params.limiter?.reset(params.ip, params.rateLimitScope); + return { ok: true, method: "token" }; +} + export async function authorizeGatewayConnect( params: AuthorizeGatewayConnectParams, ): Promise { @@ -372,6 +398,12 @@ export async function authorizeGatewayConnect( const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const authSurface = params.authSurface ?? "http"; const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface); + 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 localDirect = isLocalDirectRequest( req, trustedProxies, @@ -379,6 +411,9 @@ export async function authorizeGatewayConnect( ); if (auth.mode === "trusted-proxy") { + // Same-host reverse proxies may forward identity headers without a full + // forwarded chain; keep those on the trusted-proxy path so allowUsers and + // requiredHeaders still apply. Only raw local-direct traffic falls back. if (!auth.trustedProxy) { return { ok: false, reason: "trusted_proxy_config_missing" }; } @@ -386,6 +421,30 @@ export async function authorizeGatewayConnect( return { ok: false, reason: "trusted_proxy_no_proxies_configured" }; } + const proxyUserHeader = auth.trustedProxy?.userHeader?.toLowerCase(); + const hasProxyIdentityHeader = + proxyUserHeader !== undefined && Boolean(req?.headers?.[proxyUserHeader]); + if (localDirect && !hasProxyIdentityHeader) { + if (limiter) { + const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); + if (!rlCheck.allowed) { + return { + ok: false, + reason: "rate_limited", + rateLimited: true, + retryAfterMs: rlCheck.retryAfterMs, + }; + } + } + return authorizeTokenAuth({ + authToken: auth.token, + connectToken: connectAuth?.token, + limiter, + ip, + rateLimitScope, + }); + } + const result = authorizeTrustedProxy({ req, trustedProxies, @@ -402,12 +461,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) { @@ -436,21 +489,13 @@ export async function authorizeGatewayConnect( } if (auth.mode === "token") { - if (!auth.token) { - return { ok: false, reason: "token_missing_config" }; - } - if (!connectAuth?.token) { - // Don't burn rate-limit slots for missing credentials — the client - // simply hasn't provided a token yet (e.g. bare browser open). - // Only actual *wrong* credentials should count as failures. - return { ok: false, reason: "token_missing" }; - } - 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 authorizeTokenAuth({ + authToken: auth.token, + connectToken: connectAuth?.token, + limiter, + ip, + rateLimitScope, + }); } if (auth.mode === "password") { diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 5c1354d7cd5..2fcffcea1ad 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -76,18 +76,6 @@ describe("resolveGatewayRuntimeConfig", () => { expectedMessage: "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured", }, - { - name: "loopback binding without loopback trusted proxy", - cfg: { - gateway: { - bind: "loopback" as const, - auth: TRUSTED_PROXY_AUTH, - trustedProxies: ["10.0.0.1"], - }, - }, - expectedMessage: - "gateway auth mode=trusted-proxy with bind=loopback requires gateway.trustedProxies to include 127.0.0.1, ::1, or a loopback CIDR", - }, { name: "lan binding without trusted proxies", cfg: { @@ -106,6 +94,22 @@ describe("resolveGatewayRuntimeConfig", () => { expectedMessage, ); }); + + it("allows loopback binding with non-loopback trusted proxies", async () => { + const result = await resolveGatewayRuntimeConfig({ + cfg: { + gateway: { + bind: "loopback", + auth: TRUSTED_PROXY_AUTH, + trustedProxies: ["10.0.0.1"], + }, + }, + port: 18789, + }); + + expect(result.authMode).toBe("trusted-proxy"); + expect(result.bindHost).toBe("127.0.0.1"); + }); }); describe("token/password auth modes", () => { diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 6262208eeaf..f7cc5d1718f 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -11,12 +11,7 @@ import { } from "./auth.js"; import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveHooksConfig } from "./hooks.js"; -import { - isLoopbackHost, - isTrustedProxyAddress, - isValidIPv4, - resolveGatewayBindHost, -} from "./net.js"; +import { isLoopbackHost, isValidIPv4, resolveGatewayBindHost } from "./net.js"; import { mergeGatewayTailscaleConfig } from "./startup-auth.js"; export type GatewayRuntimeConfig = { @@ -152,16 +147,6 @@ export async function resolveGatewayRuntimeConfig(params: { "gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP", ); } - if (isLoopbackHost(bindHost)) { - const hasLoopbackTrustedProxy = - isTrustedProxyAddress("127.0.0.1", trustedProxies) || - isTrustedProxyAddress("::1", trustedProxies); - if (!hasLoopbackTrustedProxy) { - throw new Error( - "gateway auth mode=trusted-proxy with bind=loopback requires gateway.trustedProxies to include 127.0.0.1, ::1, or a loopback CIDR", - ); - } - } } return {