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:
Aether AI
2026-02-15 01:28:52 +11:00
committed by GitHub
parent cb9a5e1cb9
commit 3967ece625
4 changed files with 62 additions and 6 deletions

View File

@@ -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);