Repository navigation
codex_oauth.py reads unverified JWT claims for security decisions and uses REFRESH_CMD.split() instead of shlex.split() #27
Description
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:
passREFRESH_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.jsonwith a forgedexpclaim (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_idin the JWT payload is forwarded as theChatGPT-Account-IDheader, potentially impersonating another account if OpenAI trusts this client-side value. - Refresh command injection: if
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
- Remove the custom
_decode_jwt_claims/_is_expiringlogic. 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()withshlex.split(REFRESH_CMD)to correctly handle quoted arguments. - Add a warning if
UC_CODEX_REFRESH_CMDis set to a non-default value.
Acceptance criteria
- Token expiry is not solely determined by the unverified JWT payload, OR code is documented as best-effort with a comment explaining the limitation.
-
shlex.splitreplacesREFRESH_CMD.split(). - No security decision (beyond UI hints) relies on the unverified
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.
Activity
Merge pull request #28 from OnlyTerp/devin/1781763007-fix-open-issues
devin-ai-integration commentedon Jun 18, 2026
Fixed in #28 (merged): REFRESH_CMD is now parsed with shlex.split (posix-aware) so quoted paths aren't split on internal spaces, and the JWT decode is documented as best-effort/unverified -- a cosmetic account-id/exp routing hint only, never an authorization decision (authority is enforced upstream by Codex).