diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ed547a2aa..d8a4d6497f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058) - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. ## 2026.2.14 diff --git a/src/agents/chutes-oauth.test.ts b/src/agents/chutes-oauth.test.ts new file mode 100644 index 00000000000..8ef91281ba8 --- /dev/null +++ b/src/agents/chutes-oauth.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { generateChutesPkce, parseOAuthCallbackInput } from "./chutes-oauth.js"; + +describe("parseOAuthCallbackInput", () => { + const EXPECTED_STATE = "abc123def456"; + + it("returns code and state for valid URL with matching state", () => { + const result = parseOAuthCallbackInput( + `http://localhost/cb?code=authcode_xyz&state=${EXPECTED_STATE}`, + EXPECTED_STATE, + ); + expect(result).toEqual({ code: "authcode_xyz", state: EXPECTED_STATE }); + }); + + it("rejects URL with mismatched state (CSRF protection)", () => { + const result = parseOAuthCallbackInput( + "http://localhost/cb?code=authcode_xyz&state=attacker_state", + EXPECTED_STATE, + ); + expect(result).toHaveProperty("error"); + expect((result as { error: string }).error).toMatch(/state mismatch/i); + }); + + it("rejects bare code input without fabricating state", () => { + const result = parseOAuthCallbackInput("bare_auth_code", EXPECTED_STATE); + expect(result).toHaveProperty("error"); + expect(result).not.toHaveProperty("code"); + }); + + it("rejects empty input", () => { + const result = parseOAuthCallbackInput("", EXPECTED_STATE); + expect(result).toEqual({ error: "No input provided" }); + }); + + it("rejects URL missing code parameter", () => { + const result = parseOAuthCallbackInput( + `http://localhost/cb?state=${EXPECTED_STATE}`, + EXPECTED_STATE, + ); + expect(result).toHaveProperty("error"); + }); + + it("rejects URL missing state parameter", () => { + const result = parseOAuthCallbackInput("http://localhost/cb?code=authcode_xyz", EXPECTED_STATE); + expect(result).toHaveProperty("error"); + }); +}); + +describe("generateChutesPkce", () => { + it("returns verifier and challenge strings", () => { + const pkce = generateChutesPkce(); + expect(pkce.verifier).toMatch(/^[0-9a-f]{64}$/); + expect(pkce.challenge).toBeTruthy(); + }); +}); diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index 63ba4e26cb8..405c3d84143 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -52,12 +52,12 @@ export function parseOAuthCallbackInput( if (!state) { return { error: "Missing 'state' parameter. Paste the full URL." }; } + if (state !== expectedState) { + return { error: "OAuth state mismatch - possible CSRF attack. Please retry login." }; + } return { code, state }; } catch { - if (!expectedState) { - return { error: "Paste the full redirect URL, not just the code." }; - } - return { code: trimmed, state: expectedState }; + return { error: "Paste the full redirect URL, not just the code." }; } } diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index 1925649bb4a..161ae621db0 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -156,7 +156,7 @@ export async function loginChutes(params: { await params.onAuth({ url }); params.onProgress?.("Waiting for redirect URL…"); const input = await params.onPrompt({ - message: "Paste the redirect URL (or authorization code)", + message: "Paste the redirect URL", placeholder: `${params.app.redirectUri}?code=...&state=...`, }); const parsed = parseOAuthCallbackInput(String(input), state); @@ -176,7 +176,7 @@ export async function loginChutes(params: { }).catch(async () => { params.onProgress?.("OAuth callback not detected; paste redirect URL…"); const input = await params.onPrompt({ - message: "Paste the redirect URL (or authorization code)", + message: "Paste the redirect URL", placeholder: `${params.app.redirectUri}?code=...&state=...`, }); const parsed = parseOAuthCallbackInput(String(input), state);