Summary
codex_oauth.py reads a ChatGPT/Codex OAuth access token from ~/.codex/auth.json and decodes it client-side using a hand-rolled base64 JWT parser (_decode_jwt_claims) with no signature verification. The token is then forwarded as a Bearer credential to https://chatgpt.com/backend-api/codex. Expired token detection relies solely on the exp claim in the unverified JWT payload.
Evidence
providers/codex_oauth.py, _decode_jwt_claims() (lines ~65–72):
def _decode_jwt_claims(token: str) -> dict:
try:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
except Exception:
return {}
_is_expiring() trusts the decoded exp claim directly:
def _is_expiring(token: str, skew: int = 120) -> bool:
claims = _decode_jwt_claims(token)
exp = claims.get("exp")
if not isinstance(exp, (int, float)):
return False
return time.time() >= (exp - skew)
_account_id() similarly trusts the chatgpt_account_id from the unverified payload:
def _account_id(token: str):
claims = _decode_jwt_claims(token)
auth = claims.get("https://api.openai.com/auth") or {}
return auth.get("chatgpt_account_id")
This chatgpt_account_id is set as the ChatGPT-Account-ID request header sent upstream.
Additionally, _best_effort_refresh() runs REFRESH_CMD.split() as a subprocess:
def _best_effort_refresh() -> None:
if not REFRESH_CMD:
return
try:
subprocess.run(REFRESH_CMD.split(), timeout=25,
capture_output=True, check=False)
except Exception:
pass
REFRESH_CMD defaults to "codex login status" but is configurable via UC_CODEX_REFRESH_CMD. A value like UC_CODEX_REFRESH_CMD="sh -c 'malicious command'" would execute arbitrary shell commands (though .split() prevents shell injection in the default case, a command containing spaces in its arguments would be incorrectly split).
Why this matters
- JWT tampering is undetected: a compromised
~/.codex/auth.json with a forged exp claim (set far in the future) would never trigger a token refresh, allowing a stale or revoked token to be used indefinitely.
- Account ID forgery: a forged
chatgpt_account_id in the JWT payload is forwarded as the ChatGPT-Account-ID header, potentially impersonating another account if OpenAI trusts this client-side value.
- Refresh command injection: if
UC_CODEX_REFRESH_CMD contains arguments with embedded spaces (e.g., "mycommand 'arg with space'"), .split() will corrupt the argument list, causing the refresh to silently fail.
Root cause
Using client-side JWT claim reading for security decisions (token validity, account identity) without signature verification. JWT signatures exist precisely to prevent client-side tampering; skipping verification defeats this.
Recommended fix
- Remove the custom
_decode_jwt_claims / _is_expiring logic. Instead, always attempt the API call and let the upstream return a 401 to trigger re-authentication.
- If client-side expiry checking is desired for UX, document that it is best-effort only and never use the decoded account ID as a security-critical value.
- Replace
REFRESH_CMD.split() with shlex.split(REFRESH_CMD) to correctly handle quoted arguments.
- Add a warning if
UC_CODEX_REFRESH_CMD is set to a non-default value.
Acceptance criteria
Suggested labels
security, bug
Priority
P2
Severity
Medium — the attack requires write access to ~/.codex/auth.json (same user), which limits practical impact; however, the account ID forwarding is a correctness issue even without an attacker.
Confidence
Confirmed — code is explicit; JWT signature verification is absent.
Summary
codex_oauth.pyreads a ChatGPT/Codex OAuth access token from~/.codex/auth.jsonand decodes it client-side using a hand-rolled base64 JWT parser (_decode_jwt_claims) with no signature verification. The token is then forwarded as aBearercredential tohttps://chatgpt.com/backend-api/codex. Expired token detection relies solely on theexpclaim in the unverified JWT payload.Evidence
providers/codex_oauth.py,_decode_jwt_claims()(lines ~65–72):_is_expiring()trusts the decodedexpclaim directly:_account_id()similarly trusts thechatgpt_account_idfrom the unverified payload:This
chatgpt_account_idis set as theChatGPT-Account-IDrequest header sent upstream.Additionally,
_best_effort_refresh()runsREFRESH_CMD.split()as a subprocess:REFRESH_CMDdefaults to"codex login status"but is configurable viaUC_CODEX_REFRESH_CMD. A value likeUC_CODEX_REFRESH_CMD="sh -c 'malicious command'"would execute arbitrary shell commands (though.split()prevents shell injection in the default case, a command containing spaces in its arguments would be incorrectly split).Why this matters
~/.codex/auth.jsonwith a forgedexpclaim (set far in the future) would never trigger a token refresh, allowing a stale or revoked token to be used indefinitely.chatgpt_account_idin the JWT payload is forwarded as theChatGPT-Account-IDheader, potentially impersonating another account if OpenAI trusts this client-side value.UC_CODEX_REFRESH_CMDcontains arguments with embedded spaces (e.g.,"mycommand 'arg with space'"),.split()will corrupt the argument list, causing the refresh to silently fail.Root cause
Using client-side JWT claim reading for security decisions (token validity, account identity) without signature verification. JWT signatures exist precisely to prevent client-side tampering; skipping verification defeats this.
Recommended fix
_decode_jwt_claims/_is_expiringlogic. Instead, always attempt the API call and let the upstream return a 401 to trigger re-authentication.REFRESH_CMD.split()withshlex.split(REFRESH_CMD)to correctly handle quoted arguments.UC_CODEX_REFRESH_CMDis set to a non-default value.Acceptance criteria
shlex.splitreplacesREFRESH_CMD.split().chatgpt_account_idclaim.Suggested labels
security, bug
Priority
P2
Severity
Medium — the attack requires write access to
~/.codex/auth.json(same user), which limits practical impact; however, the account ID forwarding is a correctness issue even without an attacker.Confidence
Confirmed — code is explicit; JWT signature verification is absent.