From 3967ece6254cbc9eb7452e190842f5be6d592c56 Mon Sep 17 00:00:00 2001 From: Aether AI Date: Sun, 15 Feb 2026 01:28:52 +1100 Subject: [PATCH] =?UTF-8?q?fix(security):=20OC-25=20=E2=80=94=20Validate?= =?UTF-8?q?=20OAuth=20state=20parameter=20to=20prevent=20CSRF=20attacks=20?= =?UTF-8?q?(#16058)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): validate OAuth state parameter to prevent CSRF attacks (OC-25) The parseOAuthCallbackInput() function in the Chutes OAuth flow had two critical bugs that completely defeated CSRF state validation: 1. State extracted from callback URL was never compared against the expected cryptographic nonce, allowing attacker-controlled state values 2. When URL parsing failed (bare authorization code input), the catch block fabricated a matching state using expectedState, making the caller's CSRF check always pass ## Attack Flow 1. Victim runs `openclaw login chutes --manual` 2. System generates cryptographic state: randomBytes(16).toString("hex") 3. Browser opens: https://api.chutes.ai/idp/authorize?state=abc123... 4. Attacker obtains their OWN OAuth authorization code (out of band) 5. Attacker tricks victim into pasting just "EVIL_CODE" (not full URL) 6. parseOAuthCallbackInput("EVIL_CODE", "abc123...") is called 7. new URL("EVIL_CODE") throws → catch block executes 8. catch returns { code: "EVIL_CODE", state: "abc123..." } ← FABRICATED 9. Caller checks: parsed.state !== state → "abc123..." !== "abc123..." → FALSE 10. CSRF check passes! System calls exchangeChutesCodeForTokens() 11. Attacker's code exchanged for access + refresh tokens 12. Victim's account linked to attacker's OAuth session Fix: - Add explicit state validation against expectedState before returning - Remove state fabrication from catch block; always return error for non-URL input - Add comprehensive unit tests for state validation Remediated by Aether AI Agent security analysis. * fix(security): harden chutes manual oauth state check (#16058) (thanks @aether-ai-agent) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/agents/chutes-oauth.test.ts | 55 +++++++++++++++++++++++++++++++++ src/agents/chutes-oauth.ts | 8 ++--- src/commands/chutes-oauth.ts | 4 +-- 4 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 src/agents/chutes-oauth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ed547a2..d8a4d6497 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 000000000..8ef91281b --- /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 63ba4e26c..405c3d841 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 1925649bb..161ae621d 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);