Medium severity5.3NVD Advisory· Published Apr 3, 2026· Updated Apr 22, 2026
CVE-2026-34511
CVE-2026-34511
Description
OpenClaw before 2026.4.2 reuses the PKCE verifier as the OAuth state parameter in the Gemini OAuth flow, exposing it through the redirect URL. Attackers who capture the redirect URL can obtain both the authorization code and PKCE verifier, defeating PKCE protection and enabling token redemption.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.2 | 2026.4.2 |
Affected products
1Patches
1a26f4d0f3ef0Separate Gemini OAuth state from PKCE verifier (#59116)
4 files changed · +79 −14
CHANGELOG.md+1 −0 modified@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987. - Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus. - Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987. +- Plugins/Google: separate OAuth CSRF state from PKCE code verifier during Gemini browser sign-in so state validation and token exchange use independent values. (#59116) Thanks @eleqtrizit. ## 2026.4.2-beta.1
extensions/google/oauth.flow.ts+8 −8 modified@@ -14,7 +14,11 @@ export function generatePkce(): { verifier: string; challenge: string } { return { verifier, challenge }; } -export function buildAuthUrl(challenge: string, verifier: string): string { +export function generateOAuthState(): string { + return randomBytes(32).toString("hex"); +} + +export function buildAuthUrl(challenge: string, state: string): string { const { clientId } = resolveOAuthClientConfig(); const params = new URLSearchParams({ client_id: clientId, @@ -23,7 +27,7 @@ export function buildAuthUrl(challenge: string, verifier: string): string { scope: SCOPES.join(" "), code_challenge: challenge, code_challenge_method: "S256", - state: verifier, + state, access_type: "offline", prompt: "consent", }); @@ -32,7 +36,6 @@ export function buildAuthUrl(challenge: string, verifier: string): string { export function parseCallbackInput( input: string, - expectedState: string, ): { code: string; state: string } | { error: string } { const trimmed = input.trim(); if (!trimmed) { @@ -42,7 +45,7 @@ export function parseCallbackInput( try { const url = new URL(trimmed); const code = url.searchParams.get("code"); - const state = url.searchParams.get("state") ?? expectedState; + const state = url.searchParams.get("state"); if (!code) { return { error: "Missing 'code' parameter in URL" }; } @@ -51,10 +54,7 @@ export function parseCallbackInput( } 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." }; } }
extensions/google/oauth.test.ts+62 −0 modified@@ -279,6 +279,13 @@ describe("loginGeminiCliOAuth", () => { }); } + function getFormField(body: RequestInit["body"], name: string): string | null { + if (!(body instanceof URLSearchParams)) { + throw new Error("Expected URLSearchParams body"); + } + return body.get(name); + } + type LoginGeminiCliOAuthFn = (options: { isRemote: boolean; openUrl: () => Promise<void>; @@ -399,6 +406,61 @@ describe("loginGeminiCliOAuth", () => { }); }); + it("keeps OAuth state separate from the PKCE verifier during manual login", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = getRequestUrl(input); + requests.push({ url, init }); + + if (url === TOKEN_URL) { + return responseJson({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + if (url === USERINFO_URL) { + return responseJson({ email: "lobster@openclaw.ai" }); + } + if (url === LOAD_PROD) { + return responseJson({ + currentTier: { id: "standard-tier" }, + cloudaicompanionProject: { id: "prod-project" }, + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const { loginGeminiCliOAuth } = await import("./oauth.js"); + const { authUrl } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); + + const authState = new URL(authUrl).searchParams.get("state"); + expect(authState).toBeTruthy(); + + const tokenRequest = requests.find((request) => request.url === TOKEN_URL); + expect(tokenRequest).toBeDefined(); + const codeVerifier = getFormField(tokenRequest?.init?.body, "code_verifier"); + expect(codeVerifier).toBeTruthy(); + expect(codeVerifier).not.toBe(authState); + }); + + it("rejects manual callback input when the returned state does not match", async () => { + const { loginGeminiCliOAuth } = await import("./oauth.js"); + + await expect( + loginGeminiCliOAuth({ + isRemote: true, + openUrl: async () => {}, + log: () => {}, + note: async () => {}, + prompt: async () => + "http://localhost:8085/oauth2callback?code=oauth-code&state=wrong-state", + progress: { update: () => {}, stop: () => {} }, + }), + ).rejects.toThrow("OAuth state mismatch - please try again"); + }); + it("falls back to GOOGLE_CLOUD_PROJECT when all loadCodeAssist endpoints fail", async () => { process.env.GOOGLE_CLOUD_PROJECT = "env-project";
extensions/google/oauth.ts+8 −6 modified@@ -1,6 +1,7 @@ import { clearCredentialsCache, extractGeminiCliCredentials } from "./oauth.credentials.js"; import { buildAuthUrl, + generateOAuthState, generatePkce, parseCallbackInput, shouldUseManualOAuthFlow, @@ -32,18 +33,19 @@ export async function loginGeminiCliOAuth( ); const { verifier, challenge } = generatePkce(); - const authUrl = buildAuthUrl(challenge, verifier); + const state = generateOAuthState(); + const authUrl = buildAuthUrl(challenge, state); if (needsManual) { ctx.progress.update("OAuth URL ready"); ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); ctx.progress.update("Waiting for you to paste the callback URL..."); const callbackInput = await ctx.prompt("Paste the redirect URL here: "); - const parsed = parseCallbackInput(callbackInput, verifier); + const parsed = parseCallbackInput(callbackInput); if ("error" in parsed) { throw new Error(parsed.error); } - if (parsed.state !== verifier) { + if (parsed.state !== state) { throw new Error("OAuth state mismatch - please try again"); } ctx.progress.update("Exchanging authorization code for tokens..."); @@ -59,7 +61,7 @@ export async function loginGeminiCliOAuth( try { const { code } = await waitForLocalCallback({ - expectedState: verifier, + expectedState: state, timeoutMs: 5 * 60 * 1000, onProgress: (msg) => ctx.progress.update(msg), }); @@ -75,11 +77,11 @@ export async function loginGeminiCliOAuth( ctx.progress.update("Local callback server failed. Switching to manual mode..."); ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); const callbackInput = await ctx.prompt("Paste the redirect URL here: "); - const parsed = parseCallbackInput(callbackInput, verifier); + const parsed = parseCallbackInput(callbackInput); if ("error" in parsed) { throw new Error(parsed.error, { cause: err }); } - if (parsed.state !== verifier) { + if (parsed.state !== state) { throw new Error("OAuth state mismatch - please try again", { cause: err }); } ctx.progress.update("Exchanging authorization code for tokens...");
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/a26f4d0f3ef0757db6c6c40277cc06a5de76c52fnvdPatchWEB
- github.com/advisories/GHSA-9jpj-g8vv-j5mfghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-9jpj-g8vv-j5mfnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-34511ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-pkce-verifier-exposure-via-oauth-state-parameternvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.