mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-09 15:35:17 +00:00
fix(security): OC-25 — Validate OAuth state parameter to prevent CSRF attacks (#16058)
* 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 <steipete@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
55
src/agents/chutes-oauth.test.ts
Normal file
55
src/agents/chutes-oauth.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user