CVE-2026-61687: Hatchet OAuth State CSRF via Empty-State Collision
Hatchet clears the OAuth anti-CSRF state to an empty string instead of deleting it from the session, so any callback request carrying an empty state parameter bypasses the check and can hijack an…
The problem
After a successful OAuth callback, Hatchet's ValidateOAuthState helper writes "" (empty string) back to the session key oauth_state_<integration> rather than deleting it. A subsequent equality check of the form sessionState == requestState then passes whenever ?state= is absent or explicitly empty.
Any deployment with Google, GitHub, or Slack OAuth enabled is affected. A victim who has completed at least one OAuth flow in their current session holds a poisoned session key. An attacker can send them a crafted callback URL with state= omitted, binding the victim's live session to an attacker-controlled OAuth account (login-CSRF / account takeover).
No authentication is required on the attacker's side.
Proof of concept
A working proof-of-concept for CVE-2026-61687 in hatchet, with the exact payload below.
# Precondition: victim has completed at least one OAuth flow this session.
# The session key oauth_state_google now holds "" (empty string).
# Step 1 - attacker initiates a real Google OAuth flow for their own account,
# captures the ?code= from Google's redirect to the attacker's browser.
# Step 2 - attacker sends the victim this crafted callback URL (state= is empty):
GET /api/v1/users/google/callback?code=ATTACKER_AUTH_CODE&state= HTTP/1.1
Host: victim-hatchet.example.com
Cookie: session=VICTIM_SESSION_COOKIE
# ValidateOAuthState reads oauth_state_google == ""
# and compares it to the request state == ""
# -> strings are equal -> validation passes
# -> victim's session is now linked to the attacker's Google identityThe root cause is a write-then-compare bug: after a valid callback the code does session.Set("oauth_state_"+integration, "") and saves, instead of session.Delete("oauth_state_"+integration). The guard that was meant to prevent replay simply checks storedState != incomingState; when both sides are "" the condition is false and the check is skipped entirely.
The fix in commit f90464189ad642251e09412d0f99fde353036428 deletes the session key after use (so a missing key can never collide with an empty ?state=) and adds an explicit rejection when the stored or incoming state is empty, closing both the collision and the blank-state injection path (CWE-287, CWE-352, CWE-384).
The fix
Upgrade to hatchet v0.91.1 or later. The patch commit is f90464189ad642251e09412d0f99fde353036428. If an immediate upgrade is not possible, disable all OAuth integrations (auth.google.enabled: false, auth.github.enabled: false, Slack integration off) until the upgrade is applied.
Related research
- criticalCVE-2026-88007CVE-2026-88007: Traefik HTTP/3 NTLM Backend Connection Reuse
- critical · 9.8CVE-2026-88018CVE-2026-88018: rclone serve s3 SigV4 Authentication Bypass via Empty Secret
- high · 7.6CVE-2026-73292CVE-2026-73292: Semaphore UI CSRF Password Takeover
- high · 5.9CVE-2026-55761CVE-2026-55761: Portainer Unauthenticated Admin Takeover via Initialization Endpoints