diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f944b361..fcd8e457c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -628,6 +628,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: 8.11.1 - name: Install Android SDK packages run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edf42b68..4eda32488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,11 @@ Docs: https://docs.clawd.bot -## 2026.1.25 +## 2026.1.24-3 -### Changes -- TBD. +### Fixes +- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie. +- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie. ## 2026.1.24-2 diff --git a/docs/gateway/security.md b/docs/gateway/security.md index ed0054411..05e1673c6 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -63,6 +63,23 @@ downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`. `clawdbot security audit` warns when this setting is enabled. +## Reverse Proxy Configuration + +If you run the Gateway behind a reverse proxy (nginx, Caddy, Traefik, etc.), you should configure `gateway.trustedProxies` for proper client IP detection. + +When the Gateway detects proxy headers (`X-Forwarded-For` or `X-Real-IP`) from an address that is **not** in `trustedProxies`, it will **not** treat connections as local clients. If gateway auth is disabled, those connections are rejected. This prevents authentication bypass where proxied connections would otherwise appear to come from localhost and receive automatic trust. + +```yaml +gateway: + trustedProxies: + - "127.0.0.1" # if your proxy runs on localhost + auth: + mode: password + password: ${CLAWDBOT_GATEWAY_PASSWORD} +``` + +When `trustedProxies` is configured, the Gateway will use `X-Forwarded-For` headers to determine the real client IP for local client detection. Make sure your proxy overwrites (not appends to) incoming `X-Forwarded-For` headers to prevent spoofing. + ## Local session logs live on disk Clawdbot stores session transcripts on disk under `~/.clawdbot/agents//sessions/*.jsonl`. diff --git a/docs/help/faq.md b/docs/help/faq.md index 4949f9292..7a5ca6ce8 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -105,6 +105,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely) - [Tailscale is connected but I get no replies. What now?](#tailscale-is-connected-but-i-get-no-replies-what-now) - [Can two Clawdbots talk to each other (local + VPS)?](#can-two-clawdbots-talk-to-each-other-local-vps) + - [Do I need separate VPSes for multiple agents](#do-i-need-separate-vpses-for-multiple-agents) - [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps) - [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) @@ -1463,6 +1464,16 @@ allowlists, or a "do not reply to bot messages" rule). Docs: [Remote access](/gateway/remote), [Agent CLI](/cli/agent), [Agent send](/tools/agent-send). +### Do I need separate VPSes for multiple agents + +No. One Gateway can host multiple agents, each with its own workspace, model defaults, +and routing. That is the normal setup and it is much cheaper and simpler than running +one VPS per agent. + +Use separate VPSes only when you need hard isolation (security boundaries) or very +different configs that you do not want to share. Otherwise, keep one Gateway and +use multiple agents or sub-agents. + ### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS Yes - nodes are the first‑class way to reach your laptop from a remote Gateway, and they diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 7f37951cb..d2d267661 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -30,17 +30,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.24-1 \ +APP_VERSION=2026.1.24-3 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.zip +ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.dmg +scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.24-1 \ +APP_VERSION=2026.1.24-3 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24-1.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24-3.dSYM.zip ``` ## Appcast entry Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24-1.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24-3.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. ## Publish & verify -- Upload `Clawdbot-2026.1.24-1.zip` (and `Clawdbot-2026.1.24-1.dSYM.zip`) to the GitHub release for tag `v2026.1.24-1`. +- Upload `Clawdbot-2026.1.24-3.zip` (and `Clawdbot-2026.1.24-3.dSYM.zip`) to the GitHub release for tag `v2026.1.24-3`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index d776d9be3..a6ee8ee98 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { join } from "node:path"; +import { join, parse } from "node:path"; // Mock fs module before importing the module under test const mockExistsSync = vi.fn(); @@ -19,7 +19,9 @@ vi.mock("node:fs", async (importOriginal) => { }); describe("extractGeminiCliCredentials", () => { - const normalizePath = (value: string) => value.replace(/\\/g, "/"); + const normalizePath = (value: string) => + value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); + const rootDir = parse(process.cwd()).root || "/"; const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com"; const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123"; const FAKE_OAUTH2_CONTENT = ` @@ -49,11 +51,33 @@ describe("extractGeminiCliCredentials", () => { }); it("extracts credentials from oauth2.js in known path", async () => { - const fakeBinDir = "/fake/bin"; + const fakeBinDir = join(rootDir, "fake", "bin"); const fakeGeminiPath = join(fakeBinDir, "gemini"); - const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; - const fakeOauth2Path = - "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + const fakeResolvedPath = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "dist", + "index.js", + ); + const fakeOauth2Path = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ); process.env.PATH = fakeBinDir; @@ -77,9 +101,18 @@ describe("extractGeminiCliCredentials", () => { }); it("returns null when oauth2.js cannot be found", async () => { - const fakeBinDir = "/fake/bin"; + const fakeBinDir = join(rootDir, "fake", "bin"); const fakeGeminiPath = join(fakeBinDir, "gemini"); - const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; + const fakeResolvedPath = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "dist", + "index.js", + ); process.env.PATH = fakeBinDir; @@ -95,11 +128,33 @@ describe("extractGeminiCliCredentials", () => { }); it("returns null when oauth2.js lacks credentials", async () => { - const fakeBinDir = "/fake/bin"; + const fakeBinDir = join(rootDir, "fake", "bin"); const fakeGeminiPath = join(fakeBinDir, "gemini"); - const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; - const fakeOauth2Path = - "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + const fakeResolvedPath = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "dist", + "index.js", + ); + const fakeOauth2Path = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ); process.env.PATH = fakeBinDir; @@ -118,11 +173,33 @@ describe("extractGeminiCliCredentials", () => { }); it("caches credentials after first extraction", async () => { - const fakeBinDir = "/fake/bin"; + const fakeBinDir = join(rootDir, "fake", "bin"); const fakeGeminiPath = join(fakeBinDir, "gemini"); - const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; - const fakeOauth2Path = - "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + const fakeResolvedPath = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "dist", + "index.js", + ); + const fakeOauth2Path = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ); process.env.PATH = fakeBinDir; diff --git a/package.json b/package.json index 39e7b31d2..5d77e25d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.24-2", + "version": "2026.1.24-3", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index a2645a75d..17a8802b2 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -351,6 +351,27 @@ describe("gateway server auth/connect", () => { } }); + test("rejects proxied connections without auth when proxy headers are untrusted", async () => { + const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + const port = await getFreePort(); + const server = await startGatewayServer(port); + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { "x-forwarded-for": "203.0.113.10" }, + }); + await new Promise((resolve) => ws.once("open", resolve)); + const res = await connectReq(ws); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("gateway auth required"); + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + test("accepts device token auth for paired device", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 5ef3f26e7..35265ce63 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -26,7 +26,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js"; import { authorizeGatewayConnect } from "../../auth.js"; import { loadConfig } from "../../../config/config.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLocalGatewayAddress, resolveGatewayClientIp } from "../../net.js"; +import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { type ConnectParams, @@ -177,7 +177,24 @@ export function attachGatewayWsMessageHandler(params: { const configSnapshot = loadConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies }); - const isLocalClient = isLocalGatewayAddress(clientIp); + + // If proxy headers are present but the remote address isn't trusted, don't treat + // the connection as local. This prevents auth bypass when running behind a reverse + // proxy without proper configuration - the proxy's loopback connection would otherwise + // cause all external requests to be treated as trusted local clients. + const hasProxyHeaders = Boolean(forwardedFor || realIp); + const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies); + const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy; + const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp); + const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp; + + if (hasUntrustedProxyHeaders) { + logWsControl.warn( + "Proxy headers detected from untrusted address. " + + "Connection will not be treated as local. " + + "Configure gateway.trustedProxies to restore local client detection behind your proxy.", + ); + } const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client); @@ -322,6 +339,31 @@ export function attachGatewayWsMessageHandler(params: { const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; const allowInsecureControlUi = isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; + if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") { + setHandshakeState("failed"); + setCloseCause("proxy-auth-required", { + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "gateway auth required behind reverse proxy", + { + details: { + hint: "set gateway.auth or configure gateway.trustedProxies", + }, + }, + ), + }); + close(1008, "gateway auth required"); + return; + } if (!device) { const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth; @@ -581,7 +623,7 @@ export function attachGatewayWsMessageHandler(params: { clientMode: connectParams.client.mode, role, scopes, - remoteIp: clientIp, + remoteIp: reportedClientIp, silent: isLocalClient, }); const context = buildRequestContext(); @@ -665,7 +707,7 @@ export function attachGatewayWsMessageHandler(params: { clientMode: connectParams.client.mode, role, scopes, - remoteIp: clientIp, + remoteIp: reportedClientIp, }); } } @@ -714,7 +756,7 @@ export function attachGatewayWsMessageHandler(params: { if (presenceKey) { upsertPresence(presenceKey, { host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(), - ip: isLocalClient ? undefined : clientIp, + ip: isLocalClient ? undefined : reportedClientIp, version: connectParams.client.version, platform: connectParams.client.platform, deviceFamily: connectParams.client.deviceFamily, @@ -773,7 +815,9 @@ export function attachGatewayWsMessageHandler(params: { setHandshakeState("connected"); if (role === "node") { const context = buildRequestContext(); - const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: clientIp }); + const nodeSession = context.nodeRegistry.register(nextClient, { + remoteIp: reportedClientIp, + }); const instanceIdRaw = connectParams.client.instanceId; const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : ""; const nodeIdsForPairing = new Set([nodeSession.nodeId]); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index cd7df057e..2ee7e27ee 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -53,6 +53,55 @@ describe("security audit", () => { ).toBe(true); }); + it("warns when loopback control UI lacks trusted proxies", async () => { + const cfg: ClawdbotConfig = { + gateway: { + bind: "loopback", + controlUi: { enabled: true }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.trusted_proxies_missing", + severity: "warn", + }), + ]), + ); + }); + + it("flags loopback control UI without auth as critical", async () => { + const cfg: ClawdbotConfig = { + gateway: { + bind: "loopback", + controlUi: { enabled: true }, + auth: { mode: "none" as any }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.loopback_no_auth", + severity: "critical", + }), + ]), + ); + }); + it("flags logging.redactSensitive=off", async () => { const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" }, diff --git a/src/security/audit.ts b/src/security/audit.ts index 87e6e3397..3695cf049 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -207,6 +207,10 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode }); + const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; + const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) + ? cfg.gateway.trustedProxies + : []; if (bind !== "loopback" && auth.mode === "none") { findings.push({ @@ -218,6 +222,32 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding }); } + if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) { + findings.push({ + checkId: "gateway.trusted_proxies_missing", + severity: "warn", + title: "Reverse proxy headers are not trusted", + detail: + "gateway.bind is loopback and gateway.trustedProxies is empty. " + + "If you expose the Control UI through a reverse proxy, configure trusted proxies " + + "so local-client checks cannot be spoofed.", + remediation: + "Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only.", + }); + } + + if (bind === "loopback" && controlUiEnabled && auth.mode === "none") { + findings.push({ + checkId: "gateway.loopback_no_auth", + severity: "critical", + title: "Gateway auth disabled on loopback", + detail: + "gateway.bind is loopback and gateway.auth is disabled. " + + "If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.", + remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.", + }); + } + if (tailscaleMode === "funnel") { findings.push({ checkId: "gateway.tailscale_funnel",