mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 05:10:29 +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>
204 lines
6.2 KiB
TypeScript
204 lines
6.2 KiB
TypeScript
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
|
import { randomBytes } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js";
|
|
import {
|
|
CHUTES_AUTHORIZE_ENDPOINT,
|
|
exchangeChutesCodeForTokens,
|
|
generateChutesPkce,
|
|
parseOAuthCallbackInput,
|
|
} from "../agents/chutes-oauth.js";
|
|
import { isLoopbackHost } from "../gateway/net.js";
|
|
|
|
type OAuthPrompt = {
|
|
message: string;
|
|
placeholder?: string;
|
|
};
|
|
|
|
function buildAuthorizeUrl(params: {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
scopes: string[];
|
|
state: string;
|
|
challenge: string;
|
|
}): string {
|
|
const qs = new URLSearchParams({
|
|
client_id: params.clientId,
|
|
redirect_uri: params.redirectUri,
|
|
response_type: "code",
|
|
scope: params.scopes.join(" "),
|
|
state: params.state,
|
|
code_challenge: params.challenge,
|
|
code_challenge_method: "S256",
|
|
});
|
|
return `${CHUTES_AUTHORIZE_ENDPOINT}?${qs.toString()}`;
|
|
}
|
|
|
|
async function waitForLocalCallback(params: {
|
|
redirectUri: string;
|
|
expectedState: string;
|
|
timeoutMs: number;
|
|
onProgress?: (message: string) => void;
|
|
}): Promise<{ code: string; state: string }> {
|
|
const redirectUrl = new URL(params.redirectUri);
|
|
if (redirectUrl.protocol !== "http:") {
|
|
throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`);
|
|
}
|
|
const hostname = redirectUrl.hostname || "127.0.0.1";
|
|
if (!isLoopbackHost(hostname)) {
|
|
throw new Error(
|
|
`Chutes OAuth redirect hostname must be loopback (got ${hostname}). Use http://127.0.0.1:<port>/...`,
|
|
);
|
|
}
|
|
const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80;
|
|
const expectedPath = redirectUrl.pathname || "/";
|
|
|
|
return await new Promise<{ code: string; state: string }>((resolve, reject) => {
|
|
let timeout: NodeJS.Timeout | null = null;
|
|
const server = createServer((req, res) => {
|
|
try {
|
|
const requestUrl = new URL(req.url ?? "/", redirectUrl.origin);
|
|
if (requestUrl.pathname !== expectedPath) {
|
|
res.statusCode = 404;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Not found");
|
|
return;
|
|
}
|
|
|
|
const code = requestUrl.searchParams.get("code")?.trim();
|
|
const state = requestUrl.searchParams.get("state")?.trim();
|
|
|
|
if (!code) {
|
|
res.statusCode = 400;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Missing code");
|
|
return;
|
|
}
|
|
if (!state || state !== params.expectedState) {
|
|
res.statusCode = 400;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Invalid state");
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.end(
|
|
[
|
|
"<!doctype html>",
|
|
"<html><head><meta charset='utf-8' /></head>",
|
|
"<body><h2>Chutes OAuth complete</h2>",
|
|
"<p>You can close this window and return to OpenClaw.</p></body></html>",
|
|
].join(""),
|
|
);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
server.close();
|
|
resolve({ code, state });
|
|
} catch (err) {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
server.close();
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
server.once("error", (err) => {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
server.close();
|
|
reject(err);
|
|
});
|
|
server.listen(port, hostname, () => {
|
|
params.onProgress?.(`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`);
|
|
});
|
|
|
|
timeout = setTimeout(() => {
|
|
try {
|
|
server.close();
|
|
} catch {}
|
|
reject(new Error("OAuth callback timeout"));
|
|
}, params.timeoutMs);
|
|
});
|
|
}
|
|
|
|
export async function loginChutes(params: {
|
|
app: ChutesOAuthAppConfig;
|
|
manual?: boolean;
|
|
timeoutMs?: number;
|
|
createPkce?: typeof generateChutesPkce;
|
|
createState?: () => string;
|
|
onAuth: (event: { url: string }) => Promise<void>;
|
|
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
|
onProgress?: (message: string) => void;
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<OAuthCredentials> {
|
|
const createPkce = params.createPkce ?? generateChutesPkce;
|
|
const createState = params.createState ?? (() => randomBytes(16).toString("hex"));
|
|
|
|
const { verifier, challenge } = createPkce();
|
|
const state = createState();
|
|
const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000;
|
|
|
|
const url = buildAuthorizeUrl({
|
|
clientId: params.app.clientId,
|
|
redirectUri: params.app.redirectUri,
|
|
scopes: params.app.scopes,
|
|
state,
|
|
challenge,
|
|
});
|
|
|
|
let codeAndState: { code: string; state: string };
|
|
if (params.manual) {
|
|
await params.onAuth({ url });
|
|
params.onProgress?.("Waiting for redirect URL…");
|
|
const input = await params.onPrompt({
|
|
message: "Paste the redirect URL",
|
|
placeholder: `${params.app.redirectUri}?code=...&state=...`,
|
|
});
|
|
const parsed = parseOAuthCallbackInput(String(input), state);
|
|
if ("error" in parsed) {
|
|
throw new Error(parsed.error);
|
|
}
|
|
if (parsed.state !== state) {
|
|
throw new Error("Invalid OAuth state");
|
|
}
|
|
codeAndState = parsed;
|
|
} else {
|
|
const callback = waitForLocalCallback({
|
|
redirectUri: params.app.redirectUri,
|
|
expectedState: state,
|
|
timeoutMs,
|
|
onProgress: params.onProgress,
|
|
}).catch(async () => {
|
|
params.onProgress?.("OAuth callback not detected; paste redirect URL…");
|
|
const input = await params.onPrompt({
|
|
message: "Paste the redirect URL",
|
|
placeholder: `${params.app.redirectUri}?code=...&state=...`,
|
|
});
|
|
const parsed = parseOAuthCallbackInput(String(input), state);
|
|
if ("error" in parsed) {
|
|
throw new Error(parsed.error);
|
|
}
|
|
if (parsed.state !== state) {
|
|
throw new Error("Invalid OAuth state");
|
|
}
|
|
return parsed;
|
|
});
|
|
|
|
await params.onAuth({ url });
|
|
codeAndState = await callback;
|
|
}
|
|
|
|
params.onProgress?.("Exchanging code for tokens…");
|
|
return await exchangeChutesCodeForTokens({
|
|
app: params.app,
|
|
code: codeAndState.code,
|
|
codeVerifier: verifier,
|
|
fetchFn: params.fetchFn,
|
|
});
|
|
}
|