mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-08-19 01:15:04 -05:00
User-supplied api_key_file and api_url preferences fed pgAdmin's LLM provider clients without validation. An authenticated user could read arbitrary server-side files (LFI) or coerce pgAdmin into requesting internal targets such as 169.254.169.254 (SSRF) via the chat path and model-list endpoints. - validate_api_key_path() restricts user-supplied paths to the user's private storage directory in server mode (covering both old- and new-style names) or the home directory in desktop mode; resolves symlinks and rejects null bytes. Shared storage is intentionally excluded since API keys are per-user secrets. - _read_api_key_from_file() caps reads at 1024 bytes and enforces a printable-ASCII no-whitespace key shape so it cannot be repurposed as an arbitrary file reader. - validate_api_url() enforces config.ALLOWED_LLM_API_URLS by exact scheme://host:port match, applied at refresh endpoints, accessor fallbacks, and provider client constructors so the chat path is also covered. Logs a startup warning if the allowlist is empty. - Adds test coverage for path validation, URL validation, refresh- endpoint rejection paths, and refresh-endpoint happy paths. Reported-by: j3seer <jasserchebbi@outlook.com>
592 lines
17 KiB
Python
592 lines
17 KiB
Python
##########################################################################
|
|
#
|
|
# pgAdmin 4 - PostgreSQL Tools
|
|
#
|
|
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
|
# This software is released under the PostgreSQL Licence
|
|
#
|
|
##########################################################################
|
|
|
|
"""Utility functions for LLM configuration access."""
|
|
|
|
import os
|
|
from pgadmin.utils.preferences import Preferences
|
|
import config
|
|
|
|
|
|
class LLMApiError(Exception):
|
|
"""User-facing error from LLM API operations.
|
|
|
|
Only messages explicitly constructed for user display should be
|
|
wrapped in this exception. Endpoints catch LLMApiError to show
|
|
the message; all other exceptions get a generic error.
|
|
"""
|
|
pass
|
|
|
|
|
|
def _get_user_storage_dirs():
|
|
"""
|
|
Compute possible storage directory paths for the current user
|
|
WITHOUT creating them. Returns a list of candidate directories
|
|
(new-style first, then old-style if different).
|
|
|
|
Returns an empty list if not in server mode or if the user
|
|
cannot be determined.
|
|
"""
|
|
from flask_security import current_user
|
|
from pgadmin.utils.paths import preprocess_username
|
|
|
|
if not config.SERVER_MODE:
|
|
return []
|
|
|
|
storage_dir = getattr(config, 'STORAGE_DIR', None)
|
|
if not storage_dir:
|
|
return []
|
|
|
|
base = (storage_dir.decode('utf-8')
|
|
if hasattr(storage_dir, 'decode') else storage_dir)
|
|
|
|
try:
|
|
# New-style: full username
|
|
username_new = preprocess_username(current_user.username)
|
|
# Old-style: username split at @
|
|
username_old = preprocess_username(
|
|
current_user.username.split('@')[0]
|
|
)
|
|
except Exception:
|
|
return []
|
|
|
|
dirs = [os.path.join(base, username_new)]
|
|
if username_old != username_new:
|
|
dirs.append(os.path.join(base, username_old))
|
|
return dirs
|
|
|
|
|
|
def _is_within(expanded, allowed):
|
|
"""Return True if expanded equals allowed or is a subpath of it."""
|
|
return expanded == allowed or expanded.startswith(allowed + os.sep)
|
|
|
|
|
|
def validate_api_key_path(file_path):
|
|
"""
|
|
Validate that a file path is within the allowed directory.
|
|
|
|
In server mode, the file must be within the current user's private
|
|
storage directory (checks both new-style and old-style naming).
|
|
Shared storage (config.SHARED_STORAGE) is intentionally excluded:
|
|
API keys are per-user secrets and must not live in directories
|
|
visible to other users.
|
|
|
|
In desktop mode, the file must be within the user's home directory.
|
|
|
|
Returns the resolved canonical path if valid, None otherwise.
|
|
"""
|
|
if not file_path:
|
|
return None
|
|
|
|
try:
|
|
expanded = os.path.realpath(os.path.expanduser(file_path))
|
|
except (ValueError, TypeError):
|
|
# Reject paths with embedded null bytes or non-string types
|
|
return None
|
|
|
|
if config.SERVER_MODE:
|
|
for storage_dir in _get_user_storage_dirs():
|
|
if _is_within(expanded, os.path.realpath(storage_dir)):
|
|
return expanded
|
|
return None
|
|
|
|
# Desktop mode: home directory
|
|
allowed = os.path.realpath(os.path.expanduser('~'))
|
|
if _is_within(expanded, allowed):
|
|
return expanded
|
|
return None
|
|
|
|
|
|
def validate_api_url(url):
|
|
"""
|
|
Validate that a URL is in the allowed LLM API URL list.
|
|
|
|
Compares the scheme://host:port portion of the URL against
|
|
config.ALLOWED_LLM_API_URLS. Path is not checked — different
|
|
providers use different paths.
|
|
|
|
Returns True if the URL is allowed, False otherwise.
|
|
An empty allowlist means no restriction (admin opt-out).
|
|
"""
|
|
from urllib.parse import urlparse
|
|
|
|
if not url:
|
|
return False
|
|
|
|
allowed_urls = getattr(config, 'ALLOWED_LLM_API_URLS', [])
|
|
if not allowed_urls:
|
|
return True
|
|
|
|
parsed = urlparse(url)
|
|
scheme = parsed.scheme.lower()
|
|
hostname = parsed.hostname
|
|
if hostname:
|
|
hostname = hostname.lower()
|
|
|
|
if not scheme or not hostname:
|
|
return False
|
|
|
|
# Only allow http and https schemes
|
|
if scheme not in ('http', 'https'):
|
|
return False
|
|
|
|
# Infer default port from scheme if not specified
|
|
try:
|
|
port = parsed.port
|
|
except ValueError:
|
|
return False
|
|
if port is None:
|
|
port = 443 if scheme == 'https' else 80
|
|
|
|
request_origin = f'{scheme}://{hostname}:{port}'
|
|
|
|
for allowed in allowed_urls:
|
|
a_parsed = urlparse(allowed)
|
|
a_scheme = a_parsed.scheme.lower()
|
|
a_hostname = a_parsed.hostname
|
|
if a_hostname:
|
|
a_hostname = a_hostname.lower()
|
|
try:
|
|
a_port = a_parsed.port
|
|
except ValueError:
|
|
continue
|
|
if a_port is None:
|
|
if a_scheme in ('https', 'http'):
|
|
a_port = 443 if a_scheme == 'https' else 80
|
|
else:
|
|
continue
|
|
|
|
allowed_origin = f'{a_scheme}://{a_hostname}:{a_port}'
|
|
|
|
if request_origin == allowed_origin:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _read_api_key_from_file(file_path, _trusted=False):
|
|
"""
|
|
Read an API key from a file.
|
|
|
|
Args:
|
|
file_path: Path to the file containing the API key.
|
|
_trusted: If True, skip path validation. Use ONLY for
|
|
admin-configured paths from config.py, never for
|
|
user-supplied input.
|
|
|
|
Returns:
|
|
The API key string, or None if the file doesn't exist, is empty,
|
|
or doesn't look like a valid API key file.
|
|
"""
|
|
if not file_path:
|
|
return None
|
|
|
|
if _trusted:
|
|
# Admin-configured path: resolve but skip directory check
|
|
try:
|
|
expanded_path = os.path.realpath(
|
|
os.path.expanduser(file_path)
|
|
)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
else:
|
|
# User-supplied path: reject paths outside allowed directory.
|
|
# validate_api_key_path resolves symlinks and relative
|
|
# components via realpath, so use its result directly.
|
|
expanded_path = validate_api_key_path(file_path)
|
|
if expanded_path is None:
|
|
return None
|
|
|
|
if not os.path.isfile(expanded_path):
|
|
return None
|
|
|
|
try:
|
|
with open(expanded_path, 'r') as f:
|
|
raw = f.read(1025)
|
|
if len(raw) > 1024:
|
|
return None
|
|
key = raw.strip()
|
|
if not key:
|
|
return None
|
|
# An API key should be printable ASCII with no
|
|
# whitespace. Reject anything else to prevent misuse
|
|
# as an arbitrary file reader.
|
|
if not all(c.isascii() and c.isprintable() and
|
|
not c.isspace() for c in key):
|
|
return None
|
|
return key
|
|
except (IOError, OSError):
|
|
return None
|
|
|
|
|
|
# Public alias for use by refresh endpoints
|
|
read_api_key_file = _read_api_key_from_file
|
|
|
|
|
|
def _get_preference_value(name):
|
|
"""
|
|
Get a preference value, returning None if empty or not set.
|
|
|
|
Args:
|
|
name: The preference name (e.g., 'anthropic_api_key_file')
|
|
|
|
Returns:
|
|
The preference value or None if empty/not set.
|
|
"""
|
|
try:
|
|
pref_module = Preferences.module('ai')
|
|
if pref_module:
|
|
pref = pref_module.preference(name)
|
|
if pref:
|
|
value = pref.get()
|
|
if value and str(value).strip():
|
|
return str(value).strip()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def get_anthropic_api_url():
|
|
"""
|
|
Get the Anthropic API URL.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
User-preference URLs are validated against the SSRF allowlist.
|
|
|
|
Returns:
|
|
The URL string, or empty string if not configured.
|
|
"""
|
|
# Check user preference first
|
|
pref_url = _get_preference_value('anthropic_api_url')
|
|
if pref_url:
|
|
if validate_api_url(pref_url):
|
|
return pref_url
|
|
# Preference URL not in allowlist — fall through to config
|
|
|
|
# Fall back to system configuration (trusted admin URL)
|
|
return config.ANTHROPIC_API_URL or ''
|
|
|
|
|
|
def get_anthropic_api_key():
|
|
"""
|
|
Get the Anthropic API key.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
|
|
Returns:
|
|
The API key string, or None if not configured or file doesn't exist.
|
|
"""
|
|
# Check user preference first
|
|
pref_file = _get_preference_value('anthropic_api_key_file')
|
|
if pref_file:
|
|
if validate_api_key_path(pref_file) is not None:
|
|
key = _read_api_key_from_file(pref_file)
|
|
if key:
|
|
return key
|
|
|
|
# Fall back to system configuration (trusted admin path)
|
|
return _read_api_key_from_file(
|
|
config.ANTHROPIC_API_KEY_FILE, _trusted=True
|
|
)
|
|
|
|
|
|
def get_anthropic_model():
|
|
"""
|
|
Get the Anthropic model to use.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
|
|
Returns:
|
|
The model name string, or empty string if not configured.
|
|
"""
|
|
# Check user preference first
|
|
pref_model = _get_preference_value('anthropic_api_model')
|
|
if pref_model:
|
|
return pref_model
|
|
|
|
# Fall back to system configuration
|
|
return config.ANTHROPIC_API_MODEL or ''
|
|
|
|
|
|
def get_openai_api_url():
|
|
"""
|
|
Get the OpenAI API URL.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
User-preference URLs are validated against the SSRF allowlist.
|
|
|
|
Returns:
|
|
The URL string, or empty string if not configured.
|
|
"""
|
|
# Check user preference first
|
|
pref_url = _get_preference_value('openai_api_url')
|
|
if pref_url:
|
|
if validate_api_url(pref_url):
|
|
return pref_url
|
|
# Preference URL not in allowlist — fall through to config
|
|
|
|
# Fall back to system configuration (trusted admin URL)
|
|
return config.OPENAI_API_URL or ''
|
|
|
|
|
|
def get_openai_api_key():
|
|
"""
|
|
Get the OpenAI API key.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
|
|
Returns:
|
|
The API key string, or None if not configured or file doesn't exist.
|
|
"""
|
|
# Check user preference first
|
|
pref_file = _get_preference_value('openai_api_key_file')
|
|
if pref_file:
|
|
if validate_api_key_path(pref_file) is not None:
|
|
key = _read_api_key_from_file(pref_file)
|
|
if key:
|
|
return key
|
|
|
|
# Fall back to system configuration (trusted admin path)
|
|
return _read_api_key_from_file(
|
|
config.OPENAI_API_KEY_FILE, _trusted=True
|
|
)
|
|
|
|
|
|
def get_openai_model():
|
|
"""
|
|
Get the OpenAI model to use.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
|
|
Returns:
|
|
The model name string, or empty string if not configured.
|
|
"""
|
|
# Check user preference first
|
|
pref_model = _get_preference_value('openai_api_model')
|
|
if pref_model:
|
|
return pref_model
|
|
|
|
# Fall back to system configuration
|
|
return config.OPENAI_API_MODEL or ''
|
|
|
|
|
|
def get_ollama_api_url():
|
|
"""
|
|
Get the Ollama API URL.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
User-preference URLs are validated against the SSRF allowlist.
|
|
|
|
Returns:
|
|
The URL string, or empty string if not configured.
|
|
"""
|
|
# Check user preference first
|
|
pref_url = _get_preference_value('ollama_api_url')
|
|
if pref_url:
|
|
if validate_api_url(pref_url):
|
|
return pref_url
|
|
# Preference URL not in allowlist — fall through to config
|
|
|
|
# Fall back to system configuration (trusted admin URL)
|
|
return config.OLLAMA_API_URL or ''
|
|
|
|
|
|
def get_ollama_model():
|
|
"""
|
|
Get the Ollama model to use.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
|
|
Returns:
|
|
The model name string, or empty string if not configured.
|
|
"""
|
|
# Check user preference first
|
|
pref_model = _get_preference_value('ollama_api_model')
|
|
if pref_model:
|
|
return pref_model
|
|
|
|
# Fall back to system configuration
|
|
return config.OLLAMA_API_MODEL or ''
|
|
|
|
|
|
def get_docker_api_url():
|
|
"""
|
|
Get the Docker Model Runner API URL.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
User-preference URLs are validated against the SSRF allowlist.
|
|
|
|
Returns:
|
|
The URL string, or empty string if not configured.
|
|
"""
|
|
# Check user preference first
|
|
pref_url = _get_preference_value('docker_api_url')
|
|
if pref_url:
|
|
if validate_api_url(pref_url):
|
|
return pref_url
|
|
# Preference URL not in allowlist — fall through to config
|
|
|
|
# Fall back to system configuration (trusted admin URL)
|
|
return config.DOCKER_API_URL or ''
|
|
|
|
|
|
def get_docker_model():
|
|
"""
|
|
Get the Docker Model Runner model to use.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
|
|
Returns:
|
|
The model name string, or empty string if not configured.
|
|
"""
|
|
# Check user preference first
|
|
pref_model = _get_preference_value('docker_api_model')
|
|
if pref_model:
|
|
return pref_model
|
|
|
|
# Fall back to system configuration
|
|
return config.DOCKER_API_MODEL or ''
|
|
|
|
|
|
def get_default_provider():
|
|
"""
|
|
Get the default LLM provider.
|
|
|
|
First checks if LLM is enabled at the system level (config.LLM_ENABLED).
|
|
If enabled, reads from user preferences (which default to system config).
|
|
Returns None if disabled at system level or user preference is empty.
|
|
|
|
Returns:
|
|
The provider name ('anthropic', 'openai', 'ollama', 'docker')
|
|
or None if disabled.
|
|
"""
|
|
# Check master switch first - cannot be overridden by user
|
|
if not getattr(config, 'LLM_ENABLED', False):
|
|
return None
|
|
|
|
# Valid provider values
|
|
valid_providers = {'anthropic', 'openai', 'ollama', 'docker'}
|
|
|
|
# Get preference value (includes config default if not set by user)
|
|
try:
|
|
pref_module = Preferences.module('ai')
|
|
if pref_module:
|
|
pref = pref_module.preference('default_provider')
|
|
if pref:
|
|
value = pref.get()
|
|
# Check if it's a valid provider
|
|
if value and str(value).strip() in valid_providers:
|
|
return str(value).strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# No valid provider configured
|
|
return None
|
|
|
|
|
|
def is_llm_enabled_system():
|
|
"""
|
|
Check if LLM features are enabled at the system level.
|
|
|
|
This checks the config.LLM_ENABLED setting which cannot be
|
|
overridden by user preferences.
|
|
|
|
Returns:
|
|
True if LLM is enabled in system config, False otherwise.
|
|
"""
|
|
return getattr(config, 'LLM_ENABLED', False)
|
|
|
|
|
|
def is_llm_enabled():
|
|
"""
|
|
Check if LLM features are enabled for the current user.
|
|
|
|
This checks both the system-level config (LLM_ENABLED) and
|
|
whether a valid provider is configured in user preferences.
|
|
|
|
Returns:
|
|
True if LLM is enabled and a provider is configured, False otherwise.
|
|
"""
|
|
return get_default_provider() is not None
|
|
|
|
|
|
def get_max_tool_iterations():
|
|
"""
|
|
Get the maximum number of tool iterations for AI conversations.
|
|
|
|
Checks user preferences first, then falls back to system configuration.
|
|
|
|
Returns:
|
|
The maximum tool iterations (default 20).
|
|
"""
|
|
try:
|
|
pref_module = Preferences.module('ai')
|
|
if pref_module:
|
|
pref = pref_module.preference('max_tool_iterations')
|
|
if pref:
|
|
value = pref.get()
|
|
if value is not None:
|
|
return int(value)
|
|
except Exception:
|
|
pass
|
|
|
|
# Fall back to system configuration
|
|
return getattr(config, 'MAX_LLM_TOOL_ITERATIONS', 20)
|
|
|
|
|
|
def get_llm_config():
|
|
"""
|
|
Get complete LLM configuration for all providers.
|
|
|
|
Returns:
|
|
A dictionary containing configuration for all providers:
|
|
{
|
|
'default_provider': str or None,
|
|
'enabled': bool,
|
|
'anthropic': {
|
|
'api_key': str or None,
|
|
'model': str
|
|
},
|
|
'openai': {
|
|
'api_key': str or None,
|
|
'model': str
|
|
},
|
|
'ollama': {
|
|
'api_url': str,
|
|
'model': str
|
|
},
|
|
'docker': {
|
|
'api_url': str,
|
|
'model': str
|
|
}
|
|
}
|
|
"""
|
|
return {
|
|
'default_provider': get_default_provider(),
|
|
'enabled': is_llm_enabled(),
|
|
'anthropic': {
|
|
'api_url': get_anthropic_api_url(),
|
|
'api_key': get_anthropic_api_key(),
|
|
'model': get_anthropic_model()
|
|
},
|
|
'openai': {
|
|
'api_url': get_openai_api_url(),
|
|
'api_key': get_openai_api_key(),
|
|
'model': get_openai_model()
|
|
},
|
|
'ollama': {
|
|
'api_url': get_ollama_api_url(),
|
|
'model': get_ollama_model()
|
|
},
|
|
'docker': {
|
|
'api_url': get_docker_api_url(),
|
|
'model': get_docker_model()
|
|
}
|
|
}
|