From fc98e231fe8883db78775dd9ee3608756c41dc18 Mon Sep 17 00:00:00 2001 From: Ashesh Vashi Date: Sun, 7 Jun 2026 15:45:04 +0530 Subject: [PATCH] fix(llm): expand allowlist for self-hosted endpoints and stop silent fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9900's allowlist was too narrow and #9936 reported that custom OpenAI-compatible endpoints (LiteLLM, vLLM, LM Studio, self-hosted llama.cpp, corporate proxies) silently routed to OpenAI instead. Three problems compounded: 1. The default allowlist hardcoded four URLs; anything else required editing config_local.py. 2. When the user's preference URL was rejected, get_*_api_url() silently substituted the trusted admin default — the request went through, but to a different provider with the user's key. 3. The same silent-substitution pattern existed for the ANTHROPIC_API_KEY_FILE / OPENAI_API_KEY_FILE preferences (jbro90's comment on #9936): a path that fell outside the user's private storage was dropped without surfacing the reason, leaving the AI panel asking the user to "configure the LLM first" with no actionable error. Changes: - validate_api_url() learns a ":*" port wildcard. An entry like http://localhost:* matches any port on that host without opening up other hosts. Link-local cloud-metadata addresses (169.254.169.254 etc.) remain blocked. - Default ALLOWED_LLM_API_URLS now covers http://localhost:*, http://127.0.0.1:*, and http://[::1]:* — every loopback port, including LiteLLM (4000), vLLM (8000), LM Studio (1234), and any self-hosted endpoint the user picks. - New _resolve_pref_url() / _resolve_pref_key_file() helpers collapse the four URL getters and two key-file getters to one-liners. When the user has explicitly set a preference but it fails its allowlist check, the helpers log a warning and return ''/None instead of silently substituting the admin default. The fall back to the admin default only happens when the user has NOT set a preference. - New is_pref_api_url_rejected() / is_pref_api_key_path_rejected() helpers; get_llm_client() uses them to raise clear LLMClientError messages pointing the user at ALLOWED_LLM_API_URLS / private user storage instead of the misleading "API key not configured". Adds 31 test scenarios across six test classes covering: port wildcard match/non-match (loopback only, scheme still enforced, metadata still blocked); URL/key-file resolution under preference allowed / rejected / unset; client-side error surfacing for both rejection cases. Fixes #9936 --- web/config.py | 13 +- web/pgadmin/llm/client.py | 38 +- .../llm/tests/test_api_url_validation.py | 370 +++++++++++++++++- web/pgadmin/llm/utils.py | 270 +++++++------ 4 files changed, 563 insertions(+), 128 deletions(-) diff --git a/web/config.py b/web/config.py index c8ab16233bb..70571c07ac5 100644 --- a/web/config.py +++ b/web/config.py @@ -1058,11 +1058,20 @@ # This prevents SSRF attacks via user-controlled API URL fields. # Set to an empty list to disable URL restriction (not recommended). # Add entries for custom providers (LiteLLM, LM Studio, corporate proxies). +# +# Use ``:*`` to match any port on a host — useful for local self-hosting +# where the user chooses the port. The host check still rejects +# link-local addresses like 169.254.169.254 used by cloud metadata. ALLOWED_LLM_API_URLS = [ 'https://api.anthropic.com:443', 'https://api.openai.com:443', - 'http://localhost:11434', # Ollama default - 'http://localhost:12434', # Docker Model Runner default + # Loopback addresses on any port: covers Ollama (11434), Docker + # Model Runner (12434), LiteLLM (4000), vLLM (8000), LM Studio + # (1234), text-generation-webui (5000), and any self-hosted + # OpenAI-compatible endpoint the user runs on their own machine. + 'http://localhost:*', + 'http://127.0.0.1:*', + 'http://[::1]:*', ] # Maximum Tool Iterations diff --git a/web/pgadmin/llm/client.py b/web/pgadmin/llm/client.py index e860eec4497..3c3dc823858 100644 --- a/web/pgadmin/llm/client.py +++ b/web/pgadmin/llm/client.py @@ -172,7 +172,9 @@ def get_llm_client( get_anthropic_api_url, get_anthropic_api_key, get_anthropic_model, get_openai_api_url, get_openai_api_key, get_openai_model, get_ollama_api_url, get_ollama_model, - get_docker_api_url, get_docker_model + get_docker_api_url, get_docker_model, + is_pref_api_url_rejected, + is_pref_api_key_path_rejected, ) # Determine which provider to use @@ -183,8 +185,34 @@ def get_llm_client( provider = provider.lower() + def _rejected_url_error(prov): + return LLMClientError(LLMError( + message=( + f"The configured {prov} API URL is not in the " + "allowed list (ALLOWED_LLM_API_URLS). Add it to " + "config_local.py, or clear the API URL preference " + "to use the system default." + ), + provider=prov, + )) + + def _rejected_key_file_error(prov): + return LLMClientError(LLMError( + message=( + f"The configured {prov} API key file is not within " + "your private user storage. Move the key file to " + "your private storage directory, or clear the API " + "Key File preference to use the system default." + ), + provider=prov, + )) + if provider == 'anthropic': from pgadmin.llm.providers.anthropic import AnthropicClient + if is_pref_api_url_rejected('anthropic_api_url'): + raise _rejected_url_error('anthropic') + if is_pref_api_key_path_rejected('anthropic_api_key_file'): + raise _rejected_key_file_error('anthropic') api_key = get_anthropic_api_key() api_url = get_anthropic_api_url() if not api_key and not api_url: @@ -199,6 +227,10 @@ def get_llm_client( elif provider == 'openai': from pgadmin.llm.providers.openai import OpenAIClient + if is_pref_api_url_rejected('openai_api_url'): + raise _rejected_url_error('openai') + if is_pref_api_key_path_rejected('openai_api_key_file'): + raise _rejected_key_file_error('openai') api_key = get_openai_api_key() api_url = get_openai_api_url() if not api_key and not api_url: @@ -213,6 +245,8 @@ def get_llm_client( elif provider == 'ollama': from pgadmin.llm.providers.ollama import OllamaClient + if is_pref_api_url_rejected('ollama_api_url'): + raise _rejected_url_error('ollama') api_url = get_ollama_api_url() if not api_url: raise LLMClientError(LLMError( @@ -224,6 +258,8 @@ def get_llm_client( elif provider == 'docker': from pgadmin.llm.providers.docker import DockerClient + if is_pref_api_url_rejected('docker_api_url'): + raise _rejected_url_error('docker') api_url = get_docker_api_url() if not api_url: raise LLMClientError(LLMError( diff --git a/web/pgadmin/llm/tests/test_api_url_validation.py b/web/pgadmin/llm/tests/test_api_url_validation.py index d3a73322689..2229dbe0c16 100644 --- a/web/pgadmin/llm/tests/test_api_url_validation.py +++ b/web/pgadmin/llm/tests/test_api_url_validation.py @@ -153,6 +153,51 @@ class ValidateApiUrlTestCase(BaseTestGenerator): ], expected=False, )), + ('Wildcard port: localhost:* matches arbitrary port', dict( + url='http://localhost:4000/v1/chat/completions', + allowed_urls=[ + 'http://localhost:*', + ], + expected=True, + )), + ('Wildcard port: same host, default 80 also matches', dict( + url='http://localhost/v1', + allowed_urls=[ + 'http://localhost:*', + ], + expected=True, + )), + ('Wildcard port: 127.0.0.1:* matches', dict( + url='http://127.0.0.1:8080/foo', + allowed_urls=[ + 'http://127.0.0.1:*', + ], + expected=True, + )), + ('Wildcard port: IPv6 loopback matches', dict( + url='http://[::1]:8000/foo', + allowed_urls=[ + 'http://[::1]:*', + ], + expected=True, + )), + ('Wildcard port does NOT cover other hosts', dict( + # Cloud metadata endpoint stays blocked even with + # localhost:* in the allowlist. + url='http://169.254.169.254/latest/meta-data/', + allowed_urls=[ + 'http://localhost:*', + 'http://127.0.0.1:*', + ], + expected=False, + )), + ('Wildcard port: scheme still enforced', dict( + url='https://localhost:8443/v1', + allowed_urls=[ + 'http://localhost:*', + ], + expected=False, + )), ] def setUp(self): @@ -166,14 +211,20 @@ def runTest(self): self.assertEqual(result, self.expected) -class GetApiUrlPreferenceRejectionTestCase(BaseTestGenerator): - """Test that get_*_api_url() rejects non-allowlisted preference - URLs and falls through to system config.""" +class GetApiUrlResolutionTestCase(BaseTestGenerator): + """Test the get_*_api_url() resolution rules. + + Contract (post-#9936): + - Pref URL set and allowed -> pref URL returned + - Pref URL set but rejected -> '' returned (NO fallback to + admin default; otherwise the user's request would be + silently routed to a different provider) + - Pref URL unset -> admin's config URL returned + """ scenarios = [ ('Anthropic: allowed pref URL returned', dict( getter='get_anthropic_api_url', - pref_name='anthropic_api_url', pref_value='https://api.anthropic.com/v1', config_attr='ANTHROPIC_API_URL', config_value='https://fallback.example.com/v1', @@ -182,49 +233,65 @@ class GetApiUrlPreferenceRejectionTestCase(BaseTestGenerator): ], expect='https://api.anthropic.com/v1', )), - ('Anthropic: disallowed pref falls to config', dict( + ('Anthropic: rejected pref returns "" (no fallback)', dict( getter='get_anthropic_api_url', - pref_name='anthropic_api_url', pref_value='http://169.254.169.254/', config_attr='ANTHROPIC_API_URL', config_value='https://fallback.example.com/v1', allowed_urls=[ 'https://api.anthropic.com:443', ], + expect='', + )), + ('Anthropic: unset pref returns admin config', dict( + getter='get_anthropic_api_url', + pref_value=None, + config_attr='ANTHROPIC_API_URL', + config_value='https://fallback.example.com/v1', + allowed_urls=[ + 'https://api.anthropic.com:443', + ], expect='https://fallback.example.com/v1', )), - ('Ollama: disallowed pref falls to config', dict( + ('Ollama: rejected pref returns "" (no fallback)', dict( getter='get_ollama_api_url', - pref_name='ollama_api_url', pref_value='http://10.0.0.1:8080/', config_attr='OLLAMA_API_URL', config_value='http://localhost:11434', allowed_urls=[ 'http://localhost:11434', ], - expect='http://localhost:11434', + expect='', )), - ('OpenAI: disallowed pref falls to config', dict( + ('OpenAI: rejected pref returns "" (no fallback)', dict( getter='get_openai_api_url', - pref_name='openai_api_url', pref_value='http://evil.com:9999/', config_attr='OPENAI_API_URL', - config_value='', + config_value='https://api.openai.com', allowed_urls=[ 'https://api.openai.com:443', ], expect='', )), - ('Docker: disallowed pref falls to config', dict( + ('Docker: rejected pref returns "" (no fallback)', dict( getter='get_docker_api_url', - pref_name='docker_api_url', pref_value='http://192.168.1.1:12434/', config_attr='DOCKER_API_URL', config_value='http://localhost:12434', allowed_urls=[ 'http://localhost:12434', ], - expect='http://localhost:12434', + expect='', + )), + ('LiteLLM-style pref on localhost:* allowlist', dict( + getter='get_openai_api_url', + pref_value='http://localhost:4000/v1', + config_attr='OPENAI_API_URL', + config_value='', + allowed_urls=[ + 'http://localhost:*', + ], + expect='http://localhost:4000/v1', )), ] @@ -245,3 +312,276 @@ def runTest(self): ): result = getter_fn() self.assertEqual(result, self.expect) + + +class IsPrefApiUrlRejectedTestCase(BaseTestGenerator): + """Test the is_pref_api_url_rejected() helper that client.py + uses to decide whether to surface a clear 'URL not allowed' + error instead of a generic 'not configured' error.""" + + scenarios = [ + ('No pref set', dict( + pref_value=None, + allowed_urls=['https://api.openai.com:443'], + expect=False, + )), + ('Pref allowed', dict( + pref_value='https://api.openai.com/v1', + allowed_urls=['https://api.openai.com:443'], + expect=False, + )), + ('Pref rejected', dict( + pref_value='https://litellm.example.com/v1', + allowed_urls=['https://api.openai.com:443'], + expect=True, + )), + ('Pref allowed by wildcard', dict( + pref_value='http://localhost:4000/v1', + allowed_urls=['http://localhost:*'], + expect=False, + )), + ] + + def setUp(self): + pass + + def runTest(self): + import pgadmin.llm.utils as llm_utils + + with patch( + 'config.ALLOWED_LLM_API_URLS', self.allowed_urls + ), patch.object( + llm_utils, '_get_preference_value', + return_value=self.pref_value + ): + self.assertEqual( + llm_utils.is_pref_api_url_rejected('openai_api_url'), + self.expect, + ) + + +class GetApiKeyResolutionTestCase(BaseTestGenerator): + """Test the get_*_api_key() resolution rules. + + Contract (post-#9936 follow-up for jbro90's comment): + - Pref key file set and path allowed -> read user's key + - Pref key file set but path rejected -> return None (NO + fallback to admin default; silent substitution would route + the request with a different key) + - Pref key file unset -> read admin's + trusted config key file + """ + + scenarios = [ + ('Anthropic: rejected pref path returns None', dict( + getter='get_anthropic_api_key', + pref_value='/etc/passwd', # outside storage dir + config_attr='ANTHROPIC_API_KEY_FILE', + config_value='', + path_valid=False, + read_admin_returns=None, + expect=None, + )), + ('OpenAI: rejected pref path returns None', dict( + getter='get_openai_api_key', + pref_value='/tmp/malicious.key', + config_attr='OPENAI_API_KEY_FILE', + config_value='/etc/pgadmin/admin.key', + path_valid=False, + # Even though admin has a valid key file configured, we + # do NOT silently substitute it — the user explicitly + # set their own preference. + read_admin_returns='sk-admin-fallback', + expect=None, + )), + ('Anthropic: allowed pref path returns user key', dict( + getter='get_anthropic_api_key', + pref_value='/home/u/private/anthropic.key', + config_attr='ANTHROPIC_API_KEY_FILE', + config_value='', + path_valid=True, + read_admin_returns=None, + expect='sk-user-key', + )), + ('Anthropic: unset pref falls to admin default', dict( + getter='get_anthropic_api_key', + pref_value=None, + config_attr='ANTHROPIC_API_KEY_FILE', + config_value='/etc/pgadmin/admin.key', + path_valid=True, + read_admin_returns='sk-admin-fallback', + expect='sk-admin-fallback', + )), + ] + + def setUp(self): + pass + + def runTest(self): + import pgadmin.llm.utils as llm_utils + getter_fn = getattr(llm_utils, self.getter) + + validate_ret = ('/safe' + str(self.pref_value)) \ + if self.path_valid and self.pref_value else None + + def fake_read(path, _trusted=False): + # User pref read path is the validate_api_key_path return. + if _trusted: + return self.read_admin_returns + return 'sk-user-key' + + with patch.object( + llm_utils, '_get_preference_value', + return_value=self.pref_value + ), patch.object( + llm_utils, 'validate_api_key_path', + return_value=validate_ret + ), patch.object( + llm_utils, '_read_api_key_from_file', + side_effect=fake_read + ), patch( + 'config.' + self.config_attr, self.config_value + ): + result = getter_fn() + self.assertEqual(result, self.expect) + + +class IsPrefApiKeyPathRejectedTestCase(BaseTestGenerator): + """Test the is_pref_api_key_path_rejected() helper.""" + + scenarios = [ + ('No pref set', dict( + pref_value=None, + path_valid=True, + expect=False, + )), + ('Pref allowed', dict( + pref_value='/home/u/private/key', + path_valid=True, + expect=False, + )), + ('Pref rejected', dict( + pref_value='/etc/passwd', + path_valid=False, + expect=True, + )), + ] + + def setUp(self): + pass + + def runTest(self): + import pgadmin.llm.utils as llm_utils + validate_ret = '/safe' if self.path_valid else None + + with patch.object( + llm_utils, '_get_preference_value', + return_value=self.pref_value + ), patch.object( + llm_utils, 'validate_api_key_path', + return_value=validate_ret + ): + self.assertEqual( + llm_utils.is_pref_api_key_path_rejected( + 'anthropic_api_key_file' + ), + self.expect, + ) + + +class GetLLMClientRejectedKeyFileTestCase(BaseTestGenerator): + """When the user's preference key file path is rejected by the + directory allowlist, get_llm_client() must raise a clear + LLMClientError mentioning private user storage rather than the + generic 'API key not configured' error.""" + + scenarios = [ + ('OpenAI rejected key file', dict( + provider='openai', + pref_key='openai_api_key_file', + pref_value='/etc/passwd', + )), + ('Anthropic rejected key file', dict( + provider='anthropic', + pref_key='anthropic_api_key_file', + pref_value='/tmp/leak.key', + )), + ] + + def setUp(self): + pass + + def runTest(self): + from pgadmin.llm.client import get_llm_client, LLMClientError + import pgadmin.llm.utils as llm_utils + + def fake_get_pref(name): + return self.pref_value if name == self.pref_key else None + + with patch.object( + llm_utils, '_get_preference_value', side_effect=fake_get_pref + ), patch.object( + llm_utils, 'validate_api_key_path', return_value=None + ): + with self.assertRaises(LLMClientError) as ctx: + get_llm_client(provider=self.provider) + + msg = str(ctx.exception) + self.assertIn('private', msg.lower()) + self.assertIn(self.provider, msg) + + +class GetLLMClientRejectedUrlTestCase(BaseTestGenerator): + """When the user's preference URL is blocked by the allowlist, + get_llm_client() must raise a clear LLMClientError mentioning + ALLOWED_LLM_API_URLS instead of a misleading + 'API key not configured' error.""" + + scenarios = [ + ('OpenAI rejected URL surfaces clear error', dict( + provider='openai', + pref_name='openai_api_url', + pref_value='https://litellm.example.com/v1', + allowed_urls=['https://api.openai.com:443'], + )), + ('Anthropic rejected URL surfaces clear error', dict( + provider='anthropic', + pref_name='anthropic_api_url', + pref_value='http://10.0.0.1:8080/', + allowed_urls=['https://api.anthropic.com:443'], + )), + ('Ollama rejected URL surfaces clear error', dict( + provider='ollama', + pref_name='ollama_api_url', + pref_value='http://corporate-proxy.example.com:8080/', + allowed_urls=['http://localhost:11434'], + )), + ('Docker rejected URL surfaces clear error', dict( + provider='docker', + pref_name='docker_api_url', + pref_value='http://192.168.1.1:12434/', + allowed_urls=['http://localhost:12434'], + )), + ] + + def setUp(self): + pass + + def runTest(self): + from pgadmin.llm.client import get_llm_client, LLMClientError + import pgadmin.llm.utils as llm_utils + + def fake_get_pref(name): + return self.pref_value if name == self.pref_name else None + + with patch( + 'config.ALLOWED_LLM_API_URLS', self.allowed_urls + ), patch.object( + llm_utils, '_get_preference_value', side_effect=fake_get_pref + ): + with self.assertRaises(LLMClientError) as ctx: + get_llm_client(provider=self.provider) + + msg = str(ctx.exception) + self.assertIn('ALLOWED_LLM_API_URLS', msg) + self.assertIn(self.provider, msg) diff --git a/web/pgadmin/llm/utils.py b/web/pgadmin/llm/utils.py index ed6cb0b90e6..c89b6c4f8c1 100644 --- a/web/pgadmin/llm/utils.py +++ b/web/pgadmin/llm/utils.py @@ -103,6 +103,44 @@ def validate_api_key_path(file_path): return None +def _parse_allowlist_entry(entry): + """ + Parse an allowlist entry into (scheme, hostname, port). + + ``port`` is the literal string ``'*'`` if the entry uses the + port wildcard (e.g. ``http://localhost:*``), otherwise an int. + + Returns None if the entry is malformed. + """ + from urllib.parse import urlparse + + port_wildcard = False + raw = entry + if raw.endswith(':*'): + port_wildcard = True + raw = raw[:-2] + + parsed = urlparse(raw) + scheme = parsed.scheme.lower() + hostname = parsed.hostname + if hostname: + hostname = hostname.lower() + + if not scheme or not hostname or scheme not in ('http', 'https'): + return None + + if port_wildcard: + return scheme, hostname, '*' + + try: + port = parsed.port + except ValueError: + return None + if port is None: + port = 443 if scheme == 'https' else 80 + return scheme, hostname, port + + def validate_api_url(url): """ Validate that a URL is in the allowed LLM API URL list. @@ -111,6 +149,13 @@ def validate_api_url(url): config.ALLOWED_LLM_API_URLS. Path is not checked — different providers use different paths. + Allowlist entries may use ``:*`` for the port to match any port + on that host (e.g. ``http://localhost:*`` matches localhost on + every port). This is useful for local self-hosting where the + user picks the port (LiteLLM, vLLM, LM Studio, etc.) — the + same host check still blocks link-local cloud metadata + endpoints like 169.254.169.254. + Returns True if the URL is allowed, False otherwise. An empty allowlist means no restriction (admin opt-out). """ @@ -144,27 +189,15 @@ def validate_api_url(url): 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: + parsed_entry = _parse_allowlist_entry(allowed) + if parsed_entry is None: 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}' + a_scheme, a_hostname, a_port = parsed_entry - if request_origin == allowed_origin: + if a_scheme != scheme or a_hostname != hostname: + continue + if a_port == '*' or a_port == port: return True return False @@ -252,47 +285,116 @@ def _get_preference_value(name): return None -def get_anthropic_api_url(): +def _resolve_pref_url(pref_name, config_default): """ - Get the Anthropic API URL. - - Checks user preferences first, then falls back to system configuration. - User-preference URLs are validated against the SSRF allowlist. + Resolve an API URL preference against the allowlist. - Returns: - The URL string, or empty string if not configured. + - User preference set and allowed: return it. + - User preference set but rejected: log a warning and return '' + WITHOUT falling back to the admin default. Silent substitution + hides the rejection and routes the user's request to a + different provider (issue #9936). + - No user preference: return the admin's trusted config URL. """ - # Check user preference first - pref_url = _get_preference_value('anthropic_api_url') + pref_url = _get_preference_value(pref_name) if pref_url: if validate_api_url(pref_url): return pref_url - # Preference URL not in allowlist — fall through to config + try: + from flask import current_app + current_app.logger.warning( + "LLM API URL preference '%s'=%r is not in " + "ALLOWED_LLM_API_URLS; ignoring. Add it to the " + "allowlist in config_local.py to permit this URL.", + pref_name, pref_url + ) + except Exception: + pass + return '' + return config_default or '' + - # Fall back to system configuration (trusted admin URL) - return config.ANTHROPIC_API_URL or '' +def is_pref_api_url_rejected(pref_name): + """ + Return True if the user has set a preference URL for ``pref_name`` + but it failed the allowlist check. Callers use this to distinguish + 'URL not configured' from 'URL configured but blocked' so the chat + path can surface a clear error instead of a generic one. + """ + pref_url = _get_preference_value(pref_name) + return bool(pref_url) and not validate_api_url(pref_url) -def get_anthropic_api_key(): +def _resolve_pref_key_file(pref_name, config_default): """ - Get the Anthropic API key. + Resolve an API key file preference against the path allowlist. - Checks user preferences first, then falls back to system configuration. + - User preference set and path allowed: read and return the key. + - User preference set but path rejected: log a warning and return + None WITHOUT falling back to the admin default. Silent + substitution would make the user's request go through using + a different key than they expected (the symptom in jbro90's + comment on issue #9936). + - No user preference: read from the admin's trusted config path. - Returns: - The API key string, or None if not configured or file doesn't exist. + Returns the key string, or None. """ - # Check user preference first - pref_file = _get_preference_value('anthropic_api_key_file') + pref_file = _get_preference_value(pref_name) 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 + safe_path = validate_api_key_path(pref_file) + if safe_path is None: + try: + from flask import current_app + current_app.logger.warning( + "LLM API key file preference '%s'=%r is not " + "within the allowed user storage directory; " + "ignoring. Place the key file in your private " + "user storage to use it.", + pref_name, pref_file + ) + except Exception: + pass + return None + return _read_api_key_from_file(safe_path) + return _read_api_key_from_file(config_default, _trusted=True) + + +def is_pref_api_key_path_rejected(pref_name): + """ + Return True if the user has set an API key file preference for + ``pref_name`` but the path failed the directory allowlist check. + + Used by client.py to distinguish 'no key configured' from 'key + path is rejected' when surfacing an error to the user. + """ + pref_file = _get_preference_value(pref_name) + return bool(pref_file) and validate_api_key_path(pref_file) is None + + +def get_anthropic_api_url(): + """ + Get the Anthropic API URL. + + Checks the user preference first, then falls back to system + configuration ONLY when no preference is set. A preference URL + that fails the SSRF allowlist check is dropped (and a warning is + logged) — it is NOT silently substituted with the admin default. + + Returns: + The URL string, or empty string if not configured or rejected. + """ + return _resolve_pref_url( + 'anthropic_api_url', config.ANTHROPIC_API_URL + ) + + +def get_anthropic_api_key(): + """ + Get the Anthropic API key. See :func:`_resolve_pref_key_file` + for resolution and rejection rules. + """ + return _resolve_pref_key_file( + 'anthropic_api_key_file', config.ANTHROPIC_API_KEY_FILE ) @@ -316,45 +418,19 @@ def get_anthropic_model(): 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. + Get the OpenAI API URL. See :func:`get_anthropic_api_url` for + the resolution and rejection rules. """ - # 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 '' + return _resolve_pref_url('openai_api_url', config.OPENAI_API_URL) 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. + Get the OpenAI API key. See :func:`_resolve_pref_key_file` for + resolution and rejection rules. """ - # 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 + return _resolve_pref_key_file( + 'openai_api_key_file', config.OPENAI_API_KEY_FILE ) @@ -378,23 +454,10 @@ def get_openai_model(): 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. + Get the Ollama API URL. See :func:`get_anthropic_api_url` for + the resolution and rejection rules. """ - # 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 '' + return _resolve_pref_url('ollama_api_url', config.OLLAMA_API_URL) def get_ollama_model(): @@ -417,23 +480,10 @@ def get_ollama_model(): 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. + Get the Docker Model Runner API URL. See + :func:`get_anthropic_api_url` for resolution and rejection rules. """ - # 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 '' + return _resolve_pref_url('docker_api_url', config.DOCKER_API_URL) def get_docker_model():