From 07a9e0d8342297895f3ac8f5794760cdc8787954 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 02:29:21 +0000 Subject: [PATCH 1/2] fix: resolve security audit issues for prohibitions, oauth state, and android cleartext - Restructured `MEDICAL_CAPABILITIES` in `prohibitions.py` to allow FDA low-risk general wellness features (fitness, sleep, nutrition tracking) while retaining strict blocks on clinical and diagnostic functions. - Implemented HMAC signing and verification for OAuth state parameters in `auth.py` using `CIRIS_OAUTH_STATE_SECRET` to prevent tampering/CSRF, with an unreachable-code fix for the legacy unsigned fallback path. - Disabled cleartext traffic in AndroidManifest.xml. Co-authored-by: emooreatx <3317461+emooreatx@users.noreply.github.com> --- .../logic/adapters/api/routes/auth.py | 72 +++++++++++++++++-- ciris_engine/logic/buses/prohibitions.py | 39 ++++++++-- .../androidApp/src/main/AndroidManifest.xml | 2 +- 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/ciris_engine/logic/adapters/api/routes/auth.py b/ciris_engine/logic/adapters/api/routes/auth.py index dc2542614f..f12078bf7a 100644 --- a/ciris_engine/logic/adapters/api/routes/auth.py +++ b/ciris_engine/logic/adapters/api/routes/auth.py @@ -59,6 +59,56 @@ # Module-level flag to prevent multiple attestation triggers from the endpoint _attestation_triggered_from_endpoint = False +# Module-level secret for HMAC signing of OAuth state +_OAUTH_STATE_SECRET = os.getenv("CIRIS_OAUTH_STATE_SECRET", "dev_fallback_oauth_secret_change_in_prod") + +import hmac +import time +import base64 +import json + +def _sign_oauth_state(state_data: dict) -> str: + """Sign OAuth state parameter with HMAC.""" + # 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(_OAUTH_STATE_SECRET.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) -> dict: + """Verify and decode OAuth state parameter.""" + try: + state_data = json.loads(base64.urlsafe_b64decode(state.encode()).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 + expected_sig = hmac.new( + _OAUTH_STATE_SECRET.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.warning(f"State verification failed: {e}") + raise ValueError(f"Invalid state parameter: {e}") + # OAuth Frontend Redirect Configuration # These environment variables control where users are redirected after OAuth and what parameters are included OAUTH_FRONTEND_URL = os.getenv("OAUTH_FRONTEND_URL") # e.g., https://scout.ciris.ai @@ -628,8 +678,8 @@ async def oauth_login(provider: str, request: Request, redirect_uri: Optional[st state_data["redirect_uri"] = validated_redirect_uri logger.info("OAuth login initiated with validated redirect_uri") - # Base64 encode the state JSON - state = base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode() + # Sign the state JSON with HMAC + state = _sign_oauth_state(state_data) # Use OAUTH_CALLBACK_BASE_URL environment variable, or construct from request base_url = os.getenv("OAUTH_CALLBACK_BASE_URL") @@ -1396,12 +1446,21 @@ async def oauth_callback( marketing_opt_in_from_uri = None try: - state_json = base64.urlsafe_b64decode(state.encode()).decode() - state_data = json.loads(state_json) + state_data = _verify_oauth_state(state) redirect_uri = state_data.get("redirect_uri") + except Exception as e: + # Backward compatibility: try legacy unsigned state decode during transition + logger.warning(f"State verification failed, trying legacy decode: {e}") + 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 legacy_e: + logger.error(f"Both signed and legacy state decode failed: {legacy_e}") + redirect_uri = None + try: # Defense-in-depth: Re-validate redirect_uri even from state - # (state could theoretically be tampered with) redirect_uri = validate_redirect_uri(redirect_uri) # Extract marketing_opt_in from redirect_uri query parameters @@ -1415,8 +1474,7 @@ async def oauth_callback( logger.debug(f"Decoded state: redirect_uri={redirect_uri}, marketing_opt_in={marketing_opt_in_from_uri}") except Exception as e: - # If state decode fails, log but continue (backward compatibility) - logger.warning(f"Failed to decode state parameter: {e}. Using default redirect.") + logger.warning(f"Failed to process redirect_uri from state: {e}. Using default redirect.") # Use marketing_opt_in from redirect_uri if available, otherwise use query param final_marketing_opt_in = ( diff --git a/ciris_engine/logic/buses/prohibitions.py b/ciris_engine/logic/buses/prohibitions.py index 3ec77bc9f2..523726cc2d 100644 --- a/ciris_engine/logic/buses/prohibitions.py +++ b/ciris_engine/logic/buses/prohibitions.py @@ -46,8 +46,22 @@ class ProhibitionSeverity(str, Enum): "health", "healthcare", "health_care", - "wellbeing", - "wellness", + + # Physiological/biological terms + "physiological", + "biological_monitoring", + "biosensing", + "biometric", + "biometric_analysis", + + # Care management + "care_coordination", + "care_management", + "emergency_assessment", + + # Euphemisms + "preventive_care", + "integrative_care", # Clinical terms with variants "clinical", "clinician", @@ -139,8 +153,6 @@ class ProhibitionSeverity(str, Enum): "lab_results", "lab_results_interpretation", "test_results", - "vital_signs", - "vitals", # Care types "patient_care", "medical_history", @@ -996,6 +1008,25 @@ class ProhibitionSeverity(str, Enum): # These are explicitly allowed for all agents STANDARD_OPERATIONS = { + # General wellness & fitness (FDA Low Risk exceptions) + "wellness", + "wellbeing", + "well-being", + "fitness", + "vitals", + "vital_signs", + "vital_monitoring", + "vital_statistics", + "life_signs", + "activity_tracking", + "heart_rate", + "pulse", + "sleep_tracking", + "calorie", + "nutrition_tracking", + "status_monitoring", + "lifestyle_medicine", + "data_collection", # Basic telemetry and usage "survey_design", # User feedback collection "focus_groups", # User research diff --git a/client/androidApp/src/main/AndroidManifest.xml b/client/androidApp/src/main/AndroidManifest.xml index de2bc82e29..dd49454d0b 100644 --- a/client/androidApp/src/main/AndroidManifest.xml +++ b/client/androidApp/src/main/AndroidManifest.xml @@ -22,7 +22,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.CIRIS" - android:usesCleartextTraffic="true" + android:usesCleartextTraffic="false" tools:targetApi="31"> From b8b75913b917abd103dd29443cc50791441f5167 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 03:00:14 +0000 Subject: [PATCH 2/2] fix: resolve security audit issues and align prohibitions - Implement HMAC state signing for OAuth flow using JWT_SECRET_KEY, preventing CSRF/replay vulnerabilities, and removing the legacy insecure fallback. - Move low-risk general wellness/fitness terminology (e.g. 'wellness', 'vitals', 'sleep_tracking') from the restricted `MEDICAL_CAPABILITIES` to `STANDARD_OPERATIONS` to align with FDA low-risk policies and permit personal assistant capabilities, while maintaining strict blocks on clinical features. - Disable global cleartext traffic in the Android app, creating a network security config that strictly limits unencrypted traffic to `.local` and `localhost` domains to support mDNS-based local integrations like Home Assistant. Co-authored-by: emooreatx <3317461+emooreatx@users.noreply.github.com> --- .../logic/adapters/api/routes/auth.py | 27 +++++++++---------- .../androidApp/src/main/AndroidManifest.xml | 2 +- .../main/res/xml/network_security_config.xml | 9 +++++++ 3 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 client/androidApp/src/main/res/xml/network_security_config.xml diff --git a/ciris_engine/logic/adapters/api/routes/auth.py b/ciris_engine/logic/adapters/api/routes/auth.py index f12078bf7a..98a9c262e4 100644 --- a/ciris_engine/logic/adapters/api/routes/auth.py +++ b/ciris_engine/logic/adapters/api/routes/auth.py @@ -13,6 +13,10 @@ import logging import os import secrets +import hmac +import time +import base64 +import json from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Annotated, Any, Dict, List, Optional, Set, cast @@ -60,12 +64,12 @@ _attestation_triggered_from_endpoint = False # Module-level secret for HMAC signing of OAuth state -_OAUTH_STATE_SECRET = os.getenv("CIRIS_OAUTH_STATE_SECRET", "dev_fallback_oauth_secret_change_in_prod") - -import hmac -import time -import base64 -import json +_OAUTH_STATE_SECRET = os.getenv("CIRIS_OAUTH_STATE_SECRET") or os.getenv("JWT_SECRET_KEY") +if not _OAUTH_STATE_SECRET: + raise RuntimeError( + "Missing required environment variable for OAuth state security. " + "You must set CIRIS_OAUTH_STATE_SECRET or JWT_SECRET_KEY to a secure random string." + ) def _sign_oauth_state(state_data: dict) -> str: """Sign OAuth state parameter with HMAC.""" @@ -1449,15 +1453,8 @@ async def oauth_callback( state_data = _verify_oauth_state(state) redirect_uri = state_data.get("redirect_uri") except Exception as e: - # Backward compatibility: try legacy unsigned state decode during transition - logger.warning(f"State verification failed, trying legacy decode: {e}") - 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 legacy_e: - logger.error(f"Both signed and legacy state decode failed: {legacy_e}") - redirect_uri = None + logger.error(f"State verification failed: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state parameter") try: # Defense-in-depth: Re-validate redirect_uri even from state diff --git a/client/androidApp/src/main/AndroidManifest.xml b/client/androidApp/src/main/AndroidManifest.xml index dd49454d0b..1c8ccfcc4a 100644 --- a/client/androidApp/src/main/AndroidManifest.xml +++ b/client/androidApp/src/main/AndroidManifest.xml @@ -22,7 +22,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.CIRIS" - android:usesCleartextTraffic="false" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="31"> diff --git a/client/androidApp/src/main/res/xml/network_security_config.xml b/client/androidApp/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..29b75bb67b --- /dev/null +++ b/client/androidApp/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + localhost + 127.0.0.1 + local + +