Date: 2026-04-20
Auditor: Claude Opus 4.5
Component: /home/emoore/CIRISAgent/ciris_engine/logic/adapters/api/routes/auth.py
Lines Reviewed: 541-596 (oauth_login), 1248-1371 (oauth_callback)
VULNERABILITY CONFIRMED: LOW-TO-MODERATE RISK
The OAuth state parameter implementation uses base64-encoded JSON without HMAC signature or server-side storage. While this is a deviation from best practices, the actual exploitability is LIMITED due to multiple defense-in-depth mitigations already in place.
Risk Level: LOW-TO-MODERATE (not CRITICAL as initially suggested) Exploitability: DIFFICULT (requires specific conditions) Recommended Action: IMPROVE (add HMAC), but not urgent
# Generate CSRF token
csrf_token = secrets.token_urlsafe(32)
# Encode state with CSRF token and optional redirect_uri
state_data = {"csrf": csrf_token}
if validated_redirect_uri:
state_data["redirect_uri"] = validated_redirect_uri
# Base64 encode the state JSON
state = base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()Issues Identified:
- ✗ CSRF token is generated but NEVER VALIDATED on callback
- ✗ No HMAC/signature prevents tampering
- ✗ No server-side state storage (stateless approach)
- ✗ State can be decoded, modified, and re-encoded by attacker
state_json = base64.urlsafe_b64decode(state.encode()).decode()
state_data = json.loads(state_json)
redirect_uri = state_data.get("redirect_uri")
# Defense-in-depth: Re-validate redirect_uri even from state
# (state could theoretically be tampered with)
redirect_uri = validate_redirect_uri(redirect_uri)Critical Observation: The comment on line 1277 acknowledges the vulnerability!
"state could theoretically be tampered with"
Mitigations Present:
- ✓
redirect_uriis re-validated against whitelist (line 1278) - ✓ Only whitelisted domains accepted (see
validate_redirect_uri) - ✓ OAuth provider validates redirect_uri independently
Scenario: Attacker crafts malicious state parameter
# Attacker's malicious state
import base64, json
malicious = {"csrf": "evil", "redirect_uri": "https://attacker.com/steal"}
state = base64.urlsafe_b64encode(json.dumps(malicious).encode()).decode()
# Result: eyJjc3JmIjogImV2aWwiLCAicmVkaXJlY3RfdXJpIjogImh0dHBzOi8vYXR0YWNrZXIuY29tL3N0ZWFsIn0=Attack Flow:
- Attacker initiates OAuth flow with crafted state parameter
- User authenticates with Google/GitHub/Discord
- OAuth provider redirects to CIRIS callback with attacker's state
- CIRIS decodes state and extracts
redirect_uri
Blocking Mechanism (Line 1278):
redirect_uri = validate_redirect_uri(redirect_uri) # Returns None if untrusted!The validate_redirect_uri function (lines 187-247) only allows:
- Relative paths starting with
/(same-origin) - Domains in
OAUTH_ALLOWED_REDIRECT_DOMAINSenv var OAUTH_FRONTEND_URLdomain- Private network hosts (127.0.0.1, localhost, etc.)
Result: Open redirect attack BLOCKED ✓
Scenario: Classic OAuth CSRF attack
- Attacker initiates OAuth flow and captures their own authorization code + state
- Attacker tricks victim into visiting callback URL with attacker's code/state
- Victim's session gets linked to attacker's OAuth account
CIRIS Vulnerability Assessment:
Expected Defense: Server validates that state parameter matches server-stored CSRF token
Actual Defense: ❌ NONE - CSRF token is generated but never validated
# oauth_login (line 576): csrf_token generated
csrf_token = secrets.token_urlsafe(32)
state_data = {"csrf": csrf_token}
# oauth_callback (line 1273): csrf_token extracted but NEVER checked!
state_data = json.loads(state_json)
redirect_uri = state_data.get("redirect_uri") # Only redirect_uri extracted
# MISSING: csrf validation here!However, this attack has LIMITED IMPACT because:
-
No Session Binding: CIRIS uses stateless JWT tokens, not cookies/sessions
- Attack cannot hijack existing sessions (no session to hijack)
- Each OAuth flow creates a NEW API key (line 1344)
-
OAuth Provider Protection:
- Authorization code is single-use (cannot be replayed)
- Code must be exchanged within ~10 minutes (time-limited)
- OAuth provider validates redirect_uri independently
-
No Pre-Auth State: Victim has no "logged in state" to preserve
- CIRIS creates fresh account/token each OAuth flow
- No account linking or session upgrade flows
Result: CSRF attack has LOW IMPACT but still possible
Theoretical Scenario:
- Attacker starts OAuth flow and captures state parameter
- Attacker sends phishing link to victim:
https://accounts.google.com/o/oauth2/v2/auth?...&state=<attacker_state> - Victim clicks and completes OAuth flow
- Callback creates account using victim's Google identity but attacker's state
- Tokens redirect to attacker-controlled redirect_uri (if not validated)
CIRIS Protection:
- ✓
redirect_urivalidated against whitelist (blocks attacker redirect) - ✓ OAuth provider shows which app is requesting access
- ✓ No session binding means attacker gains nothing
Result: Attack BLOCKED ✓
Critical security detail: OAuth providers (Google, GitHub, Discord) independently validate the redirect_uri parameter during token exchange.
Evidence (lines 686-692):
async with session.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": get_oauth_callback_url("google"), # MUST match registered URI
"grant_type": "authorization_code",
},
)OAuth 2.0 Specification (RFC 6749 §4.1.3):
The authorization server MUST verify that the redirect_uri matches the value registered for the client.
This means:
- Attacker cannot use arbitrary redirect_uri values
- Only pre-registered callback URLs work:
https://agents.ciris.ai/v1/auth/oauth/{agent_id}/{provider}/callback - Tampering with state cannot change where OAuth provider sends the code
Result: OAuth provider acts as second layer of defense ✓
- ❌ CSRF Token Ignored: Generated but never validated (clear bug)
⚠️ State Tampering: Attacker can modify state parameter contents⚠️ No Server-Side State: Stateless design prevents server-side validation
- ✓ Open Redirect Blocked:
validate_redirect_uriwhitelist enforced - ✓ OAuth Provider Validation: Google/GitHub/Discord validate redirect_uri independently
- ✓ No Session Hijacking: Stateless JWT design eliminates session fixation
- ✓ Single-Use Codes: OAuth authorization codes cannot be replayed
- ✓ Time-Limited Codes: Codes expire in ~10 minutes
Best-Case Attack Scenario:
- Attacker initiates OAuth flow and captures state
- Attacker tricks victim into clicking OAuth link with attacker's state
- Victim authenticates with their Google/GitHub account
- Callback creates account/token for victim's identity
- BUT: Tokens/response go to legitimate redirect_uri (whitelist enforced)
- AND: No existing session to hijack (stateless design)
Outcome: Attacker gains NOTHING USEFUL
The missing CSRF validation is a code quality issue more than a critical vulnerability.
The client MUST include the state parameter to prevent CSRF attacks.
CIRIS Status: ✓ State parameter included (but not validated)
State should be a cryptographically signed value that the server can verify.
CIRIS Status: ❌ State is unsigned base64 JSON
# Generation
state_data = {"redirect_uri": uri, "timestamp": time.time()}
signature = hmac.new(SECRET_KEY, json.dumps(state_data), 'sha256').hexdigest()
state = base64.urlsafe_b64encode(json.dumps({**state_data, "sig": signature}))
# Validation
decoded = json.loads(base64.urlsafe_b64decode(state))
expected_sig = hmac.new(SECRET_KEY, json.dumps({k:v for k,v in decoded.items() if k != "sig"}), 'sha256').hexdigest()
if decoded["sig"] != expected_sig:
raise ValueError("Invalid state signature")CIRIS Status: ❌ Not implemented
Pros:
- Prevents tampering
- Maintains stateless design
- Industry best practice
- No database/cache required
Cons:
- Requires secret key management
- Slightly more complex implementation
Implementation Complexity: MODERATE (2-4 hours)
Pros:
- Strongest security
- Can add expiration timestamps
- Full validation possible
Cons:
- Requires Redis/database
- Breaks stateless design
- More infrastructure complexity
Implementation Complexity: HIGH (8-16 hours)
Pros:
- Standard web CSRF protection
- Well-understood pattern
Cons:
- Requires session management (cookies)
- Breaks current stateless JWT design
- Not suitable for API-first architecture
Implementation Complexity: HIGH (requires architecture change)
File: ciris_engine/logic/adapters/api/routes/auth.py
- Add state signing/verification functions:
import hmac
import time
from ciris_engine.logic.services.infrastructure.secrets import SecretsService
def _sign_oauth_state(state_data: dict, secrets_service: SecretsService) -> str:
"""Sign OAuth state parameter with HMAC."""
# Get or create state signing key
state_key = secrets_service.get_or_create_secret("oauth_state_key", lambda: secrets.token_hex(32))
# Add timestamp for expiration
state_data["ts"] = int(time.time())
# Generate HMAC signature
state_json = json.dumps(state_data, sort_keys=True)
signature = hmac.new(state_key.encode(), state_json.encode(), "sha256").hexdigest()
state_data["sig"] = signature
return base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()
def _verify_oauth_state(state: str, secrets_service: SecretsService) -> dict:
"""Verify and decode OAuth state parameter."""
try:
state_data = json.loads(base64.urlsafe_b64decode(state).decode())
signature = state_data.pop("sig", None)
if not signature:
raise ValueError("Missing signature")
# Verify timestamp (prevent replay attacks)
timestamp = state_data.get("ts", 0)
if time.time() - timestamp > 600: # 10 minute expiration
raise ValueError("State expired")
# Verify HMAC
state_key = secrets_service.get_secret("oauth_state_key")
expected_sig = hmac.new(
state_key.encode(),
json.dumps(state_data, sort_keys=True).encode(),
"sha256"
).hexdigest()
if not hmac.compare_digest(signature, expected_sig):
raise ValueError("Invalid signature")
return state_data
except Exception as e:
logger.error(f"State verification failed: {e}")
raise HTTPException(status_code=400, detail="Invalid state parameter")- Update oauth_login endpoint (line 576):
# OLD
csrf_token = secrets.token_urlsafe(32)
state_data = {"csrf": csrf_token}
if validated_redirect_uri:
state_data["redirect_uri"] = validated_redirect_uri
state = base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()
# NEW
state_data = {}
if validated_redirect_uri:
state_data["redirect_uri"] = validated_redirect_uri
state = _sign_oauth_state(state_data, secrets_service)- Update oauth_callback endpoint (line 1272):
# OLD
try:
state_json = base64.urlsafe_b64decode(state.encode()).decode()
state_data = json.loads(state_json)
redirect_uri = state_data.get("redirect_uri")
except Exception as e:
logger.warning(f"Failed to decode state parameter: {e}")
# NEW
try:
state_data = _verify_oauth_state(state, auth_service.secrets_service)
redirect_uri = state_data.get("redirect_uri")
except HTTPException:
raise # Re-raise validation failures
except Exception as e:
# Backward compatibility: accept old unsigned states during transition
logger.warning(f"State verification failed, trying legacy decode: {e}")
try:
state_data = json.loads(base64.urlsafe_b64decode(state).decode())
redirect_uri = state_data.get("redirect_uri")
except Exception:
logger.error("Both signed and legacy state decode failed")
redirect_uri = None- Add dependency injection:
# oauth_login needs SecretsService
async def oauth_login(
provider: str,
request: Request,
redirect_uri: Optional[str] = None,
secrets_service: SecretsService = Depends(get_secrets_service) # ADD THIS
) -> RedirectResponse:
# oauth_callback already has auth_service, extract secrets from it
# OR add secrets_service dependencydef test_state_signing_prevents_tampering():
"""Test that tampered state is rejected."""
# Create valid signed state
state = _sign_oauth_state({"redirect_uri": "/app"}, secrets_service)
# Tamper with it
decoded = json.loads(base64.urlsafe_b64decode(state))
decoded["redirect_uri"] = "https://evil.com"
tampered = base64.urlsafe_b64encode(json.dumps(decoded).encode()).decode()
# Verify rejection
with pytest.raises(HTTPException):
_verify_oauth_state(tampered, secrets_service)
def test_state_expiration():
"""Test that old states are rejected."""
state_data = {"redirect_uri": "/app", "ts": int(time.time()) - 700}
# Sign with old timestamp
with pytest.raises(HTTPException):
_verify_oauth_state(state, secrets_service)
def test_state_signature_validation():
"""Test HMAC signature validation."""
state = _sign_oauth_state({"redirect_uri": "/app"}, secrets_service)
# Should verify successfully
result = _verify_oauth_state(state, secrets_service)
assert result["redirect_uri"] == "/app"# Add to tools/qa_runner/modules/auth.py
def test_oauth_state_tampering(api_client):
"""Test that OAuth callback rejects tampered state."""
# Initiate flow
response = api_client.get("/v1/auth/oauth/google/login")
# Extract state from redirect
redirect_url = response.headers["Location"]
state = urllib.parse.parse_qs(redirect_url)["state"][0]
# Tamper with state
decoded = json.loads(base64.urlsafe_b64decode(state))
decoded["redirect_uri"] = "https://evil.com"
tampered = base64.urlsafe_b64encode(json.dumps(decoded).encode()).decode()
# Attempt callback with tampered state
response = api_client.get(f"/v1/auth/oauth/google/callback?code=test&state={tampered}")
assert response.status_code == 400- Vulnerability Confirmed: OAuth state parameter lacks HMAC signature
- CSRF Token Generated But Never Validated: Clear implementation gap
- Actual Risk: LOW-TO-MODERATE: Multiple defense-in-depth mitigations reduce exploitability
- Not "Account Takeover Primitive": Claim overstated due to:
- Redirect URI whitelist enforcement
- OAuth provider-level validation
- Stateless JWT design (no sessions to hijack)
- Single-use authorization codes
| Aspect | Rating | Justification |
|---|---|---|
| Code Quality | ❌ POOR | CSRF token ignored, unsigned state |
| Exploitability | Multiple mitigations block practical attacks | |
| Business Impact | No sensitive data exposure, no session hijacking | |
| Compliance | Deviates from OAuth 2.0 best practices | |
| Overall Risk | Should fix, but not emergency |
Priority: MODERATE (include in next security sprint)
-
Immediate (next 2 weeks):
- Add HMAC signing to state parameter (Option 1 above)
- Add unit tests for state validation
- Add integration tests for tampering attempts
-
Short-term (next month):
- Audit other stateless token implementations for similar issues
- Review all CSRF protections across API endpoints
- Consider adding rate limiting to OAuth endpoints
-
Long-term (next quarter):
- Evaluate OAuth 2.1 compliance (draft spec with enhanced security)
- Consider PKCE (Proof Key for Code Exchange) for public clients
- Add security logging for OAuth anomalies
- RFC 6749: OAuth 2.0 Authorization Framework - https://tools.ietf.org/html/rfc6749
- OWASP OAuth Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html
- OAuth 2.0 Security Best Current Practice: https://tools.ietf.org/html/draft-ietf-oauth-security-topics
Report End