{"record":{"id":"506ebf0e492aea1e","repo":"NousResearch/hermes-agent","slug":"loopback-callback-state-mismatch-possible-csrf","errorCode":null,"errorMessage":"Loopback callback state mismatch (possible CSRF)","messagePattern":"Loopback callback state mismatch \\(possible CSRF\\)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/desktop/electron/native-oauth.ts","lineNumber":167,"sourceCode":"  const parsed = new URL(requestUrl, 'http://127.0.0.1')\n  const error = parsed.searchParams.get('error')\n\n  if (error) {\n    const desc = parsed.searchParams.get('error_description') || ''\n    throw new Error(`Gateway rejected native login: ${error}${desc ? ` (${desc})` : ''}`)\n  }\n\n  const code = parsed.searchParams.get('code') || ''\n  const state = parsed.searchParams.get('state') || ''\n\n  if (!code) {\n    throw new Error('Loopback callback missing authorization code')\n  }\n\n  if (!expectedState || state !== expectedState) {\n    // Never redeem a code that arrived with a mismatched state — it may be a\n    // forged callback trying to inject an attacker's code.\n    throw new Error('Loopback callback state mismatch (possible CSRF)')\n  }\n\n  return { code }\n}\n\n/**\n * Normalize a `/auth/native/token` (or refresh) JSON response into a\n * NativeTokenSet, validating the shape. Throws on a missing/short access\n * token so a malformed response fails loudly rather than storing junk.\n */\nexport function parseTokenResponse(body: any): NativeTokenSet {\n  const accessToken = String(body?.access_token || '')\n\n  if (!accessToken) {\n    throw new Error('Gateway token response missing access_token')\n  }\n\n  const expiresAt = Number(body?.expires_at)","sourceCodeStart":149,"sourceCodeEnd":185,"githubUrl":"https://github.com/NousResearch/hermes-agent/blob/c896c09c42910c584c4c7d2325b58c14713ea42c/apps/desktop/electron/native-oauth.ts#L149-L185","documentation":"CSRF defense per RFC 6749 §10.12: the desktop generates 'state' when starting the native OAuth flow and requires the loopback callback to return the identical value. A missing state on either side, or any mismatch, throws rather than redeeming the authorization code — because a mismatched callback may be a forged redirect injecting an attacker's code (login CSRF / code injection). This error means the defense worked; the callback should not be trusted.","triggerScenarios":"Callback's state param differs from the one generated at flow start: two concurrent login attempts where attempt B's callback is checked against attempt A's expectedState; a manually crafted/bookmarked callback URL; expectedState lost because the flow object was recreated (window/app reload mid-flow); a forged redirect from a hostile page.","commonSituations":"Double-clicking 'sign in' spawning two loopback listeners; renderer reload during login; an attacker or buggy integration redirecting to 127.0.0.1 with their own code+state.","solutions":["Cancel and restart the login flow from scratch — a mismatched state invalidates that callback by design","Ensure only one native OAuth flow runs at a time (disable the sign-in button while a flow is pending)","Avoid reloading the app/window mid-login, which discards expectedState","If it persists, check that the gateway echoes the state parameter verbatim in its redirect"],"exampleFix":"// before\nconst startBtn.onclick = () => void runNativeOAuth() // double-click spawns two flows\n\n// after\nlet flowRunning = false\nconst startBtn.onclick = () => { if (flowRunning) return; flowRunning = true; void runNativeOAuth().finally(() => { flowRunning = false }) }","handlingStrategy":"try-catch","validationCode":"// Ensure exactly one flow is live before comparing state\nif (activeFlow) throw new Error('A login flow is already in progress')\nconst expectedState = crypto.randomUUID()\nactiveFlow = { expectedState }\nconst cb = parseLoopbackCallback(requestUrl, expectedState)","typeGuard":"function isStateMismatch(e: unknown): boolean { return e instanceof Error && e.message.includes('state mismatch') }","tryCatchPattern":"try { const { code } = parseLoopbackCallback(requestUrl, expectedState) } catch (e) { if (isStateMismatch(e)) { cancelFlow(); promptRelogin('Login session expired or forged — restarting sign-in') } else throw e }","preventionTips":["Serialize login flows — never run two native OAuth flows concurrently","Never redeem a code from a callback whose state doesn't match; always restart the flow","Keep expectedState in durable memory for the flow's lifetime (no reloads mid-login)"],"tags":["oauth","csrf","security","authentication","desktop"],"backgroundTag":null,"analyzedSha":"c896c09c42910c584c4c7d2325b58c14713ea42c","analyzedAt":"2026-08-14T17:18:01.089Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}