mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
fix(backend): Add validation for LLM settings to prevent non-pro user bypass (#11113)
This commit is contained in:
@@ -11,10 +11,15 @@ from openhands.server.routes.secrets import invalidate_legacy_secrets_store
|
||||
from openhands.server.settings import (
|
||||
GETSettingsModel,
|
||||
)
|
||||
from openhands.server.settings_validation import (
|
||||
check_llm_settings_changes,
|
||||
validate_llm_settings_access,
|
||||
)
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
get_user_id,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
@@ -135,17 +140,34 @@ async def store_llm_settings(
|
||||
response_model=None,
|
||||
responses={
|
||||
200: {'description': 'Settings stored successfully', 'model': dict},
|
||||
403: {'description': 'Subscription required for pro models', 'model': dict},
|
||||
500: {'description': 'Error storing settings', 'model': dict},
|
||||
},
|
||||
)
|
||||
async def store_settings(
|
||||
settings: Settings,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> JSONResponse:
|
||||
# Check provider tokens are valid
|
||||
try:
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Check if any LLM-related settings are being changed
|
||||
llm_settings_being_changed = check_llm_settings_changes(
|
||||
settings, existing_settings
|
||||
)
|
||||
|
||||
if llm_settings_being_changed:
|
||||
has_access = await validate_llm_settings_access(user_id)
|
||||
if not has_access:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={
|
||||
'error': 'Modifying LLM settings requires an active OpenHands Pro subscription. Please upgrade your account to access LLM configuration.',
|
||||
'detail': 'Subscription required for LLM settings modifications',
|
||||
},
|
||||
)
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
settings = await store_llm_settings(settings, settings_store)
|
||||
|
||||
122
openhands/server/settings_validation.py
Normal file
122
openhands/server/settings_validation.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Settings validation utilities for LLM settings access control."""
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
|
||||
def _is_llm_setting_changing(setting_name: str, new_value, existing_settings) -> bool:
|
||||
"""Check if a specific LLM setting is being changed from its existing value.
|
||||
|
||||
Args:
|
||||
setting_name: Name of the setting to check
|
||||
new_value: New value being set
|
||||
existing_settings: Existing settings object (can be None)
|
||||
|
||||
Returns:
|
||||
bool: True if the setting is being changed, False otherwise
|
||||
"""
|
||||
if new_value is None:
|
||||
return False
|
||||
|
||||
# Handle special case for enable_default_condenser with default value
|
||||
if setting_name == 'enable_default_condenser':
|
||||
if not existing_settings:
|
||||
# First time setting - only validate if setting to non-default value
|
||||
return not new_value
|
||||
else:
|
||||
# Changing existing value
|
||||
return new_value != existing_settings.enable_default_condenser
|
||||
|
||||
# For other settings, validate if explicitly provided and different from existing
|
||||
if not existing_settings:
|
||||
return True
|
||||
|
||||
existing_value = getattr(existing_settings, setting_name, None)
|
||||
return new_value != existing_value
|
||||
|
||||
|
||||
def check_llm_settings_changes(settings: Settings, existing_settings) -> bool:
|
||||
"""Check if any LLM-related settings are being changed.
|
||||
|
||||
Validates both core LLM settings (model, API key, base URL) and advanced settings
|
||||
shown to SaaS users (confirmation mode, security analyzer, memory condenser settings).
|
||||
|
||||
Args:
|
||||
settings: New settings being applied
|
||||
existing_settings: Current settings (can be None)
|
||||
|
||||
Returns:
|
||||
bool: True if any LLM settings are being changed, False otherwise
|
||||
"""
|
||||
# Core LLM settings - always validate if provided
|
||||
core_llm_changes = any(
|
||||
[
|
||||
settings.llm_model is not None,
|
||||
settings.llm_api_key is not None,
|
||||
settings.llm_base_url is not None,
|
||||
]
|
||||
)
|
||||
|
||||
if core_llm_changes:
|
||||
return True
|
||||
|
||||
# Additional LLM settings shown to SaaS users - validate if actually changing
|
||||
advanced_llm_changes = any(
|
||||
[
|
||||
_is_llm_setting_changing(
|
||||
'confirmation_mode', settings.confirmation_mode, existing_settings
|
||||
),
|
||||
_is_llm_setting_changing(
|
||||
'security_analyzer', settings.security_analyzer, existing_settings
|
||||
),
|
||||
_is_llm_setting_changing(
|
||||
'enable_default_condenser',
|
||||
settings.enable_default_condenser,
|
||||
existing_settings,
|
||||
),
|
||||
_is_llm_setting_changing(
|
||||
'condenser_max_size', settings.condenser_max_size, existing_settings
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return advanced_llm_changes
|
||||
|
||||
|
||||
async def validate_llm_settings_access(user_id: str) -> bool:
|
||||
"""Validate if user has access to modify LLM settings in SaaS mode.
|
||||
|
||||
In SaaS mode, only pro users with active subscriptions can modify LLM settings.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to check subscription for
|
||||
|
||||
Returns:
|
||||
bool: True if user can modify LLM settings, False otherwise
|
||||
"""
|
||||
# Skip validation in non-SaaS mode
|
||||
if server_config.app_mode != AppMode.SAAS:
|
||||
return True
|
||||
|
||||
# In SaaS mode, check for active subscription for ANY LLM settings changes
|
||||
try:
|
||||
# Import here to avoid circular imports and handle enterprise mode gracefully
|
||||
from enterprise.server.routes.billing import get_subscription_access
|
||||
|
||||
subscription = await get_subscription_access(user_id)
|
||||
# The get_subscription_access function already filters for ACTIVE status,
|
||||
# so if we get a subscription back, it means it's active
|
||||
return subscription is not None
|
||||
except ImportError:
|
||||
# Enterprise billing module not available - in SaaS mode, this means
|
||||
# we can't validate subscriptions, so deny access to be safe
|
||||
logger.warning(
|
||||
'Enterprise billing module not available in SaaS mode, denying LLM settings access'
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
# On error, deny access to be safe
|
||||
logger.warning(f'Error checking subscription access for user {user_id}: {e}')
|
||||
return False
|
||||
Reference in New Issue
Block a user