mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
* 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>
207 lines
5.8 KiB
TypeScript
207 lines
5.8 KiB
TypeScript
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
|
|
export const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
|
|
export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`;
|
|
export const CHUTES_TOKEN_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/token`;
|
|
export const CHUTES_USERINFO_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/userinfo`;
|
|
|
|
const DEFAULT_EXPIRES_BUFFER_MS = 5 * 60 * 1000;
|
|
|
|
export type ChutesPkce = { verifier: string; challenge: string };
|
|
|
|
export type ChutesUserInfo = {
|
|
sub?: string;
|
|
username?: string;
|
|
created_at?: string;
|
|
};
|
|
|
|
export type ChutesOAuthAppConfig = {
|
|
clientId: string;
|
|
clientSecret?: string;
|
|
redirectUri: string;
|
|
scopes: string[];
|
|
};
|
|
|
|
export type ChutesStoredOAuth = OAuthCredentials & {
|
|
clientId?: string;
|
|
};
|
|
|
|
export function generateChutesPkce(): ChutesPkce {
|
|
const verifier = randomBytes(32).toString("hex");
|
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
return { verifier, challenge };
|
|
}
|
|
|
|
export function parseOAuthCallbackInput(
|
|
input: string,
|
|
expectedState: string,
|
|
): { code: string; state: string } | { error: string } {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) {
|
|
return { error: "No input provided" };
|
|
}
|
|
|
|
try {
|
|
const url = new URL(trimmed);
|
|
const code = url.searchParams.get("code");
|
|
const state = url.searchParams.get("state");
|
|
if (!code) {
|
|
return { error: "Missing 'code' parameter in URL" };
|
|
}
|
|
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 {
|
|
return { error: "Paste the full redirect URL, not just the code." };
|
|
}
|
|
}
|
|
|
|
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
|
|
const value = now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS;
|
|
return Math.max(value, now + 30_000);
|
|
}
|
|
|
|
export async function fetchChutesUserInfo(params: {
|
|
accessToken: string;
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<ChutesUserInfo | null> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const response = await fetchFn(CHUTES_USERINFO_ENDPOINT, {
|
|
headers: { Authorization: `Bearer ${params.accessToken}` },
|
|
});
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const data = (await response.json()) as unknown;
|
|
if (!data || typeof data !== "object") {
|
|
return null;
|
|
}
|
|
const typed = data as ChutesUserInfo;
|
|
return typed;
|
|
}
|
|
|
|
export async function exchangeChutesCodeForTokens(params: {
|
|
app: ChutesOAuthAppConfig;
|
|
code: string;
|
|
codeVerifier: string;
|
|
fetchFn?: typeof fetch;
|
|
now?: number;
|
|
}): Promise<ChutesStoredOAuth> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const now = params.now ?? Date.now();
|
|
|
|
const body = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: params.app.clientId,
|
|
code: params.code,
|
|
redirect_uri: params.app.redirectUri,
|
|
code_verifier: params.codeVerifier,
|
|
});
|
|
if (params.app.clientSecret) {
|
|
body.set("client_secret", params.app.clientSecret);
|
|
}
|
|
|
|
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Chutes token exchange failed: ${text}`);
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
access_token?: string;
|
|
refresh_token?: string;
|
|
expires_in?: number;
|
|
};
|
|
|
|
const access = data.access_token?.trim();
|
|
const refresh = data.refresh_token?.trim();
|
|
const expiresIn = data.expires_in ?? 0;
|
|
|
|
if (!access) {
|
|
throw new Error("Chutes token exchange returned no access_token");
|
|
}
|
|
if (!refresh) {
|
|
throw new Error("Chutes token exchange returned no refresh_token");
|
|
}
|
|
|
|
const info = await fetchChutesUserInfo({ accessToken: access, fetchFn });
|
|
|
|
return {
|
|
access,
|
|
refresh,
|
|
expires: coerceExpiresAt(expiresIn, now),
|
|
email: info?.username,
|
|
accountId: info?.sub,
|
|
clientId: params.app.clientId,
|
|
} as unknown as ChutesStoredOAuth;
|
|
}
|
|
|
|
export async function refreshChutesTokens(params: {
|
|
credential: ChutesStoredOAuth;
|
|
fetchFn?: typeof fetch;
|
|
now?: number;
|
|
}): Promise<ChutesStoredOAuth> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const now = params.now ?? Date.now();
|
|
|
|
const refreshToken = params.credential.refresh?.trim();
|
|
if (!refreshToken) {
|
|
throw new Error("Chutes OAuth credential is missing refresh token");
|
|
}
|
|
|
|
const clientId = params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
|
|
if (!clientId) {
|
|
throw new Error("Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).");
|
|
}
|
|
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
|
|
|
|
const body = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
client_id: clientId,
|
|
refresh_token: refreshToken,
|
|
});
|
|
if (clientSecret) {
|
|
body.set("client_secret", clientSecret);
|
|
}
|
|
|
|
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Chutes token refresh failed: ${text}`);
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
access_token?: string;
|
|
refresh_token?: string;
|
|
expires_in?: number;
|
|
};
|
|
const access = data.access_token?.trim();
|
|
const newRefresh = data.refresh_token?.trim();
|
|
const expiresIn = data.expires_in ?? 0;
|
|
|
|
if (!access) {
|
|
throw new Error("Chutes token refresh returned no access_token");
|
|
}
|
|
|
|
return {
|
|
...params.credential,
|
|
access,
|
|
refresh: newRefresh || refreshToken,
|
|
expires: coerceExpiresAt(expiresIn, now),
|
|
clientId,
|
|
} as unknown as ChutesStoredOAuth;
|
|
}
|