diff --git a/docs/admin/settings/settings_ai_summary.rst b/docs/admin/settings/settings_ai_summary.rst index df6028fd4..4e7e33cfa 100644 --- a/docs/admin/settings/settings_ai_summary.rst +++ b/docs/admin/settings/settings_ai_summary.rst @@ -27,12 +27,16 @@ configured with an ``api_key``: api_key: "sk-..." model: "llama3.2:3b" -The key is sent in an ``Authorization: Bearer`` header. There is no user -preference for it, and it is only sent to the ``base_url`` above: a user who -points the ``ai_summary_server`` preference at a server of their own gets no -``Authorization`` header. SearXNG has no indirection for secrets in -``settings.yml``, so the file holding the key should be readable by the -SearXNG process only. +The key is sent in an ``Authorization: Bearer`` header and only to the +``base_url`` above. A user who points the ``ai_summary_server`` preference at +a server of their own never gets the administrator's key; for such a server +the user configures their own key in the ``ai_summary_api_key`` preference. +SearXNG has no indirection for secrets in ``settings.yml``, so the file +holding the key should be readable by the SearXNG process only. + +The *AI Summary* tab of the preferences is only shown when the plugin is +activated in ``settings.yml`` (``active: true``); an instance that does not +offer AI summaries does not show the tab at all. .. attention:: @@ -47,9 +51,14 @@ SearXNG process only. preferences: lock: - ai_summary_server + - ai_summary_api_key - ai_summary_model - ai_summary_grounding + Locking ``ai_summary_server`` and ``ai_summary_api_key`` matters most: an + unlocked pair lets any user of the instance make SearXNG send an + ``Authorization`` header of their choosing to a host of their choosing. + .. _SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery .. autoclass:: searx.ai_summary.SettingsAISummary diff --git a/searx/ai_summary.py b/searx/ai_summary.py index e5f1b861a..c3131b3c5 100644 --- a/searx/ai_summary.py +++ b/searx/ai_summary.py @@ -56,11 +56,12 @@ class SettingsAISummary(msgspec.Struct, kw_only=True, forbid_unknown_fields=True llama.cpp started with ``--api-key``, or an LLM server behind an authenticating reverse proxy. - There is intentionally no user preference for the API key, and the key is - **only** sent to :py:obj:`SettingsAISummary.base_url`: a user who points - the ``ai_summary_server`` preference at a server of their own gets no - ``Authorization`` header, so the key can't be captured by a third party - (see :py:obj:`searx.plugins.ai_summary._server_api_key`).""" + This key is **only** sent to :py:obj:`SettingsAISummary.base_url`: a user + who points the ``ai_summary_server`` preference at a server of their own + gets no ``Authorization`` header from it, so the key can't be captured by + a third party. For their own server, users configure their own key in the + ``ai_summary_api_key`` preference (see + :py:obj:`searx.plugins.ai_summary._server_api_key`).""" model: str = "" """Name of the default model (e.g. ``llama3.2:3b``). If empty, the first @@ -73,10 +74,12 @@ class SettingsAISummary(msgspec.Struct, kw_only=True, forbid_unknown_fields=True and :py:obj:`SettingsAISummary.base_url` is set, the list is requested once at application setup from the LLM server (``GET /v1/models``).""" - grounding: bool = False + grounding: bool = True """Default of the ``ai_summary_grounding`` user preference: ground the - summary on the search results. Users can still opt in/out in the - preferences unless that preference is locked.""" + summary on the search results. Grounded summaries are more accurate and + more current at a moderate extra cost (the search results are sent along + with the query, so the prompt is longer). Users can still opt in/out in + the preferences unless that preference is locked.""" connect_timeout: float = 5.0 """Timeout (seconds) to establish a TCP connection to the LLM server.""" diff --git a/searx/plugins/ai_summary.py b/searx/plugins/ai_summary.py index 4ae36013d..02767eeab 100644 --- a/searx/plugins/ai_summary.py +++ b/searx/plugins/ai_summary.py @@ -36,9 +36,10 @@ applied to reach an LLM server in the local network. A server that requires authentication (e.g. vLLM or llama.cpp started with ``--api-key``, or an LLM server behind an authenticating reverse proxy) is -configured with an ``api_key``. The key is administrator configuration only: -it is never exposed in the preferences and it is only sent to the server in -``base_url``, never to a server a user configured (:py:obj:`_server_api_key`). +configured with an ``api_key``. The administrator's key is only sent to the +server in ``base_url``, never to a server a user configured; for their own +server users configure their own key in the ``ai_summary_api_key`` preference +(:py:obj:`_server_api_key`). Configuration of the defaults (:py:obj:`searx.ai_summary.SettingsAISummary`): @@ -133,20 +134,22 @@ def _server_id(url: str) -> tuple[str, str, int, str] | None: return (parsed.scheme, parsed.hostname.lower(), port, path) -def _server_api_key(cfg: SettingsAISummary, server: str) -> str: - """The API key to send to ``server``: the administrator's - :py:obj:`cfg.api_key ` if - ``server`` *is* the administrator's server, an empty string otherwise. +def _server_api_key(cfg: SettingsAISummary, server: str, user_api_key: str = "") -> str: + """The API key to send to ``server``: - Users can point the ``ai_summary_server`` preference at a server of their - own; without this check such a server would be sent the instance's API - key, which would hand every user of the instance a way to capture it.""" - if not cfg.api_key: - return "" + - the administrator's :py:obj:`cfg.api_key + ` if ``server`` *is* the + administrator's server (:py:obj:`cfg.base_url + `), + - otherwise the user's own ``ai_summary_api_key`` preference, which belongs + to the server in the user's own ``ai_summary_server`` preference. + + The administrator's key is never sent to a server a user configured -- + that would hand every user of the instance a way to capture it.""" server_id = _server_id(server) - if server_id is None or server_id != _server_id(cfg.base_url): - return "" - return cfg.api_key + if server_id is not None and server_id == _server_id(cfg.base_url): + return cfg.api_key + return user_api_key def _user_server(request: "SXNG_Request", cfg: SettingsAISummary) -> str: @@ -308,7 +311,8 @@ def ai_summary_view() -> flask.Response: # open the upstream connection before streaming, a connection error is # reported as HTTP 502 instead of a line in an already started stream - client = _get_client(server, cfg, _server_api_key(cfg, server)) + user_api_key = str(sxng_request.preferences.get_value("ai_summary_api_key") or "").strip() + client = _get_client(server, cfg, _server_api_key(cfg, server, user_api_key)) stream_ctx = client.stream("POST", "/chat/completions", json=chat_payload) upstream = None try: diff --git a/searx/preferences.py b/searx/preferences.py index 1de38b6fd..df7d80ecd 100644 --- a/searx/preferences.py +++ b/searx/preferences.py @@ -49,10 +49,14 @@ class ValidationException(Exception): class Setting: """Base class of user settings""" - def __init__(self, default_value: t.Any, locked: bool = False): + def __init__(self, default_value: t.Any, locked: bool = False, secret: bool = False): super().__init__() self.value: t.Any = default_value self.locked: bool = locked + self.secret: bool = secret + """The value is a credential: it is not included in the preferences URL + (:py:obj:`Preferences.get_as_url_params`), which users copy around to + transfer or share their preferences.""" def parse(self, data: str): """Parse ``data`` and store the result at ``self.value`` @@ -468,6 +472,13 @@ class Preferences: "", locked="ai_summary_server" in self.cfg.lock, ), + 'ai_summary_api_key': StringSetting( + "", + locked="ai_summary_api_key" in self.cfg.lock, + # a user's API key is only sent to a server the user configured + # themselves, and it is never part of the preferences URL + secret=True, + ), 'ai_summary_model': StringSetting( "", locked="ai_summary_model" in self.cfg.lock, @@ -512,7 +523,7 @@ class Preferences: """Return preferences as URL parameters""" settings_kv = {} for k, v in self.key_value_settings.items(): - if v.locked: + if v.locked or v.secret: continue if isinstance(v, MultipleChoiceSetting): settings_kv[k] = ','.join(v.get_value()) diff --git a/searx/settings.yml b/searx/settings.yml index 67b0960da..e08c66c77 100644 --- a/searx/settings.yml +++ b/searx/settings.yml @@ -313,7 +313,7 @@ plugins: # # # Ground summaries on the search results by default (users can still opt # # in/out in their preferences). -# grounding: false +# grounding: true categories_as_tabs: diff --git a/searx/templates/simple/preferences.html b/searx/templates/simple/preferences.html index 732c3ee50..ac8fcaa87 100644 --- a/searx/templates/simple/preferences.html +++ b/searx/templates/simple/preferences.html @@ -251,7 +251,10 @@ {# tab: ai #} - {%- if plugins_storage | selectattr('preference_section', 'equalto', 'ai') | list -%} + {#- the tab is only shown when the administrator activated the plugin in + settings.yml, an instance without an AI summary has no AI tab -#} + {%- if 'ai_summary' in plugins_active_by_default + and plugins_storage | selectattr('preference_section', 'equalto', 'ai') | list -%} {{- tab_header('maintab', 'ai', _('AI Summary')) -}} {{- plugin_preferences('ai') -}} {%- include 'simple/preferences/ai_summary.html' -%} diff --git a/searx/templates/simple/preferences/ai_summary.html b/searx/templates/simple/preferences/ai_summary.html index c53d589a0..aaea84882 100644 --- a/searx/templates/simple/preferences/ai_summary.html +++ b/searx/templates/simple/preferences/ai_summary.html @@ -16,6 +16,19 @@ {{- '' -}} {{- '' -}} {%- endif -%} +{%- if 'ai_summary_api_key' not in locked_preferences -%} +
{{- '' -}} + {{- _('AI server API key') -}}{{- '' -}} +
{{- '' -}} + {{- '' -}} +
{{- '' -}} +
+ {{- _('Only needed if the server URL above is your own server and it requires authentication. Leave empty for a server without authentication and for the default server of this instance (which uses the key of the administrator, never yours). The key is stored in a cookie in your browser and is not part of the preferences URL.') -}} +
{{- '' -}} +
{{- '' -}} +{%- endif -%} {%- if 'ai_summary_model' not in locked_preferences -%}
{{- '' -}} {{- _('AI summary model') -}}{{- '' -}} diff --git a/searx/webapp.py b/searx/webapp.py index 23d4217f5..72c684771 100755 --- a/searx/webapp.py +++ b/searx/webapp.py @@ -980,6 +980,9 @@ def preferences(): shortcuts = {y: x for x, y in engine_shortcuts.items()}, themes = themes, plugins_storage = searx.plugins.STORAGE.info, + # plugins the administrator activated in settings.yml; a plugin that is + # not activated does not get a preferences tab of its own + plugins_active_by_default = {plg.id for plg in searx.plugins.STORAGE if plg.active}, current_doi_resolver = get_doi_resolver(), allowed_plugins = allowed_plugins, preferences_url_params = sxng_request.preferences.get_as_url_params(), diff --git a/tests/unit/test_plugin_ai_summary.py b/tests/unit/test_plugin_ai_summary.py index 01040ae08..b35da38eb 100644 --- a/tests/unit/test_plugin_ai_summary.py +++ b/tests/unit/test_plugin_ai_summary.py @@ -3,7 +3,9 @@ # pylint: disable=too-many-public-methods import json +from base64 import urlsafe_b64decode from contextlib import contextmanager +from zlib import decompress from mock import Mock @@ -79,6 +81,19 @@ class AISummaryAPIKey(SearxTestCase): self.setattr4test(self.cfg, "api_key", "") self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, BASE_URL)) + def test_user_key_goes_to_the_users_own_server(self): + key = searx.plugins.ai_summary._server_api_key(self.cfg, "http://192.168.1.10:11434", "sk-users-own") + self.assertEqual("sk-users-own", key) + + def test_user_key_does_not_override_the_admin_key(self): + # the user's key belongs to the user's server; on the admin's server + # the admin's key is the right one + key = searx.plugins.ai_summary._server_api_key(self.cfg, BASE_URL, "sk-users-own") + self.assertEqual("sk-secret", key) + + def test_no_key_for_a_user_server_without_a_user_key(self): + self.assertEqual("", searx.plugins.ai_summary._server_api_key(self.cfg, "http://192.168.1.10:11434", "")) + class PluginAISummaryInit(SearxTestCase): @@ -206,8 +221,10 @@ class PluginAISummary(SearxTestCase): answer = list(search.result_container.answers)[0] self.assertTrue(answer.grounding) - def test_grounding_default_from_settings(self): - self.setattr4test(searx.get_setting("ai_summary"), "grounding", True) + def test_grounding_is_on_by_default(self): + # note: AiSummary.__hash__ is hash(query), so two answers that differ + # only in .grounding compare equal -- assert on the attribute + self.assertTrue(searx.get_setting("ai_summary").grounding) pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage) with self.app.test_request_context(): @@ -216,6 +233,16 @@ class PluginAISummary(SearxTestCase): answer = list(search.result_container.answers)[0] self.assertTrue(answer.grounding) + def test_grounding_can_be_disabled_by_settings(self): + self.setattr4test(searx.get_setting("ai_summary"), "grounding", False) + pref = searx.preferences.Preferences(["simple"], ["general"], {}, self.storage) + + with self.app.test_request_context(): + sxng_request.preferences = pref + search = self.do_post_search("lorem ipsum") + answer = list(search.result_container.answers)[0] + self.assertFalse(answer.grounding) + def test_skip_pageno(self): with self.app.test_request_context(): sxng_request.preferences = self.pref @@ -360,6 +387,44 @@ class PluginAISummary(SearxTestCase): self.assertEqual(res.status_code, 200) self.assertEqual([(BASE_URL, "sk-secret")], calls) + def test_endpoint_sends_the_users_key_to_the_users_server(self): + self.setattr4test(searx.get_setting("ai_summary"), "api_key", "sk-secret") + calls = self.mock_upstream_recording(sse_stream_mock([])) + self.client.set_cookie("ai_summary_server", "http://192.168.1.10:11434") + self.client.set_cookie("ai_summary_api_key", "sk-users-own") + + res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]}) + self.assertEqual(res.status_code, 200) + self.assertEqual([("http://192.168.1.10:11434", "sk-users-own")], calls) + + def test_api_key_is_not_part_of_the_preferences_url(self): + # users copy the preferences URL around to transfer/share their + # settings -- a credential must not travel with it + self.pref.parse_dict({"ai_summary_api_key": "sk-users-own"}) + self.assertEqual("sk-users-own", self.pref.get_value("ai_summary_api_key")) + + blob = self.pref.get_as_url_params() + decoded = decompress(urlsafe_b64decode(blob)).decode() + self.assertNotIn("sk-users-own", decoded) + self.assertNotIn("ai_summary_api_key", decoded) + # a non-secret preference of the same tab is still included + self.assertIn("ai_summary_model", decoded) + + def test_preferences_tab_hidden_when_plugin_not_activated(self): + # the global STORAGE is what the preferences view renders from; in the + # default settings the ai_summary plugin is not activated + res = self.client.get("/preferences") + self.assertEqual(res.status_code, 200) + self.assertNotIn('tab-label-ai"', res.data.decode()) + + def test_preferences_tab_shown_when_plugin_activated(self): + self.setattr4test(searx.plugins, "STORAGE", self.storage) + res = self.client.get("/preferences") + self.assertEqual(res.status_code, 200) + html = res.data.decode() + self.assertIn('tab-label-ai"', html) + self.assertIn("ai_summary_api_key", html) + def test_endpoint_upstream_error(self): self.mock_upstream(sse_stream_mock([], status_code=500)) diff --git a/tests/unit/test_preferences.py b/tests/unit/test_preferences.py index d7009cbfd..f936f53f5 100644 --- a/tests/unit/test_preferences.py +++ b/tests/unit/test_preferences.py @@ -169,7 +169,7 @@ class TestPreferences(SearxTestCase): self.preferences.parse_encoded_data(url_params) self.assertEqual( vars(self.preferences.key_value_settings['categories']), - {'value': ['general'], 'locked': False, 'choices': ['general', 'none']}, + {'value': ['general'], 'locked': False, 'secret': False, 'choices': ['general', 'none']}, ) def test_save_key_value_setting(self):