diff --git a/docs/websearch.md b/docs/websearch.md index ded2e1b9..29dcf740 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -81,7 +81,8 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr Open `ccs config` → `Settings` → `WebSearch`. - Enable Exa, Tavily, Brave, SearXNG, or DuckDuckGo in the backend chain -- Configure SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled +- Configure the SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled + Do not include `/search`, embedded credentials, query parameters, or URL fragments. CCS appends `/search?format=json`. - Set or rotate Exa, Tavily, and Brave API keys directly inside each provider card - Saved keys are persisted in `global_env` and injected at runtime, so readiness updates from the same screen - Review whether any legacy fallback CLIs are still enabled in config diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index d1fae51e..6da24c3e 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -378,6 +378,22 @@ function getSearxngBaseUrl() { try { const parsed = new URL(raw); + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.search || parsed.hash) { + return ''; + } + + if (parsed.username || parsed.password) { + return ''; + } + + let pathname = parsed.pathname.replace(/\/+$/, ''); + if (pathname.toLowerCase().endsWith('/search')) { + pathname = pathname.slice(0, -'/search'.length); + } + + parsed.pathname = pathname || '/'; + parsed.search = ''; + parsed.hash = ''; return parsed.toString().replace(/\/+$/, ''); } catch { return ''; @@ -587,7 +603,7 @@ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const baseUrl = getSearxngBaseUrl(); if (!baseUrl) { - return { success: false, error: 'SearXNG URL is not configured' }; + return { success: false, error: 'SearXNG URL is invalid or not configured' }; } const params = new URLSearchParams({ diff --git a/lib/mcp/ccs-websearch-server.cjs b/lib/mcp/ccs-websearch-server.cjs index 13880600..1b7d207f 100644 --- a/lib/mcp/ccs-websearch-server.cjs +++ b/lib/mcp/ccs-websearch-server.cjs @@ -16,7 +16,7 @@ const SERVER_VERSION = '1.0.0'; const TOOL_NAME = 'WebSearch'; const TOOL_ALIASES = ['search']; const TOOL_DESCRIPTION = - 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.'; + 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, SearXNG, DuckDuckGo, then optional legacy CLI fallback.'; function isSupportedToolName(name) { return name === TOOL_NAME || TOOL_ALIASES.includes(name); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index e7a6a0bc..5865ba5d 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -47,6 +47,7 @@ import { resolveLegacyDiscordSelection, } from '../channels/official-channels-runtime'; import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver'; +import { normalizeSearxngBaseUrl } from '../utils/websearch/types'; const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; @@ -413,7 +414,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { }, searxng: { enabled: partial.websearch?.providers?.searxng?.enabled ?? false, - url: partial.websearch?.providers?.searxng?.url ?? '', + url: normalizeSearxngBaseUrl(partial.websearch?.providers?.searxng?.url) ?? '', max_results: partial.websearch?.providers?.searxng?.max_results ?? 5, }, duckduckgo: { @@ -1140,7 +1141,7 @@ export function getWebSearchConfig(): { const searxngConfig = { enabled: config.websearch?.providers?.searxng?.enabled ?? false, - url: config.websearch?.providers?.searxng?.url ?? '', + url: normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? '', max_results: config.websearch?.providers?.searxng?.max_results ?? 5, }; diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index d2b5f85c..2e79e649 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -7,6 +7,7 @@ */ import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { normalizeSearxngBaseUrl } from './types'; import { resolveAllowedWebSearchTraceFile } from './trace'; /** @@ -18,7 +19,18 @@ import { resolveAllowedWebSearchTraceFile } from './trace'; */ export function getWebSearchHookEnv(): Record { const wsConfig = getWebSearchConfig(); - const env: Record = {}; + const env: Record = { + CCS_WEBSEARCH_ENABLED: '0', + CCS_WEBSEARCH_SKIP: '0', + CCS_WEBSEARCH_EXA: '0', + CCS_WEBSEARCH_TAVILY: '0', + CCS_WEBSEARCH_BRAVE: '0', + CCS_WEBSEARCH_SEARXNG: '0', + CCS_WEBSEARCH_DUCKDUCKGO: '0', + CCS_WEBSEARCH_GEMINI: '0', + CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_GROK: '0', + }; if (process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1') { env.CCS_WEBSEARCH_TRACE = '1'; @@ -61,9 +73,10 @@ export function getWebSearchHookEnv(): Record { env.CCS_WEBSEARCH_BRAVE_MAX_RESULTS = String(wsConfig.providers.brave.max_results || 5); } - if (wsConfig.providers?.searxng?.enabled && wsConfig.providers.searxng.url?.trim()) { + const searxngBaseUrl = normalizeSearxngBaseUrl(wsConfig.providers?.searxng?.url); + if (wsConfig.providers?.searxng?.enabled && searxngBaseUrl) { env.CCS_WEBSEARCH_SEARXNG = '1'; - env.CCS_WEBSEARCH_SEARXNG_URL = wsConfig.providers.searxng.url.trim(); + env.CCS_WEBSEARCH_SEARXNG_URL = searxngBaseUrl; env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = String(wsConfig.providers.searxng.max_results || 5); } diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index e0b9c36d..e4efdf98 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -15,7 +15,7 @@ import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; import { getGrokCliStatus } from './grok-cli'; import { getOpenCodeCliStatus } from './opencode-cli'; import { getWebSearchApiKeyStates } from './provider-secrets'; -import type { WebSearchCliInfo, WebSearchStatus } from './types'; +import { normalizeSearxngBaseUrl, type WebSearchCliInfo, type WebSearchStatus } from './types'; const PROVIDER_STATE_FILE = 'websearch-provider-state.json'; @@ -29,17 +29,7 @@ function hasEnvValue(name: string): boolean { } function hasValidSearxngUrl(url: string | undefined): boolean { - const normalized = String(url || '').trim(); - if (!normalized) { - return false; - } - - try { - const parsed = new URL(normalized); - return Boolean(parsed.protocol && parsed.host); - } catch { - return false; - } + return normalizeSearxngBaseUrl(url) !== null; } function getProviderStatePath(): string { diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts index 4b6b3bdf..38f94499 100644 --- a/src/utils/websearch/types.ts +++ b/src/utils/websearch/types.ts @@ -117,3 +117,45 @@ export interface WebSearchConfig { grok?: WebSearchProviderConfig; }; } + +/** + * Normalize a SearXNG base URL so runtime code can safely append `/search`. + * + * Accepts optional subpaths (for reverse-proxy deployments), strips a trailing + * `/search` endpoint suffix, and rejects query/hash-bearing URLs because the + * runtime owns the request path and query params. + */ +export function normalizeSearxngBaseUrl(url: string | undefined): string | null { + const normalized = String(url || '').trim(); + if (!normalized) { + return ''; + } + + try { + const parsed = new URL(normalized); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + + if (parsed.username || parsed.password) { + return null; + } + + if (parsed.search || parsed.hash) { + return null; + } + + let pathname = parsed.pathname.replace(/\/+$/, ''); + if (pathname.toLowerCase().endsWith('/search')) { + pathname = pathname.slice(0, -'/search'.length); + } + + parsed.pathname = pathname || '/'; + parsed.search = ''; + parsed.hash = ''; + + return parsed.toString().replace(/\/+$/, ''); + } catch { + return null; + } +} diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index d5bc6432..22f88100 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -12,6 +12,7 @@ import { WEBSEARCH_API_KEY_PROVIDERS, type WebSearchApiKeyProviderId, } from '../../utils/websearch/provider-secrets'; +import { normalizeSearxngBaseUrl } from '../../utils/websearch/types'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); @@ -95,6 +96,19 @@ router.put('/', (req: Request, res: Response): void => { return; } + const normalizedSearxngUrl = + providers?.searxng?.url !== undefined + ? normalizeSearxngBaseUrl(providers.searxng.url) + : undefined; + + if (providers?.searxng?.url !== undefined && normalizedSearxngUrl === null) { + res.status(400).json({ + error: + 'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.', + }); + return; + } + if ( providers?.searxng?.max_results !== undefined && (typeof providers.searxng.max_results !== 'number' || @@ -130,6 +144,9 @@ router.put('/', (req: Request, res: Response): void => { try { mutateUnifiedConfig((config) => { + const existingSearxngUrl = + normalizeSearxngBaseUrl(config.websearch?.providers?.searxng?.url) ?? ''; + config.websearch = { enabled: enabled ?? config.websearch?.enabled ?? true, providers: providers @@ -173,7 +190,7 @@ router.put('/', (req: Request, res: Response): void => { providers.searxng?.enabled ?? config.websearch?.providers?.searxng?.enabled ?? false, - url: providers.searxng?.url ?? config.websearch?.providers?.searxng?.url ?? '', + url: normalizedSearxngUrl ?? existingSearxngUrl, max_results: clampWebSearchMaxResults( providers.searxng?.max_results, config.websearch?.providers?.searxng?.max_results ?? DEFAULT_WEBSEARCH_MAX_RESULTS diff --git a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts index 64593411..dba39c83 100644 --- a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts @@ -166,13 +166,13 @@ describe('ccs-websearch MCP server', () => { expect(toolsList?.result).toEqual({ tools: [ - { - name: 'WebSearch', - description: - 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.', - inputSchema: { - type: 'object', - properties: { + { + name: 'WebSearch', + description: + 'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, SearXNG, DuckDuckGo, then optional legacy CLI fallback.', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', description: diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 2f005e0c..775cb4f9 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -115,6 +115,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail CCS_WEBSEARCH_GEMINI: '0', CCS_WEBSEARCH_GROK: '0', CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_SEARXNG: '0', CCS_WEBSEARCH_TAVILY: '0', }, }); @@ -166,11 +167,13 @@ describe('websearch-transformer hook helpers', () => { CCS_WEBSEARCH_SEARXNG_MAX_RESULTS: process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS, CCS_WEBSEARCH_SEARXNG_URL: process.env.CCS_WEBSEARCH_SEARXNG_URL, }; + const requests: string[] = []; - process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/'; + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search/'; process.env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = '3'; - global.fetch = async () => - ({ + global.fetch = async (url) => { + requests.push(String(url)); + return { ok: true, json: async () => ({ results: [ @@ -181,13 +184,15 @@ describe('websearch-transformer hook helpers', () => { }, ], }), - }) as Response; + } as Response; + }; try { const result = await hook.trySearxngSearch('btc price', 1); expect(result.success).toBe(true); expect(result.content).toContain('Provider: SearXNG'); expect(result.content).toContain('URL: https://example.com/searxng'); + expect(requests).toEqual(['https://search.example.com/search?q=btc+price&format=json']); } finally { global.fetch = originalFetch; if (originalEnv.CCS_WEBSEARCH_SEARXNG_URL === undefined) { @@ -205,6 +210,42 @@ describe('websearch-transformer hook helpers', () => { } }); + it('rejects SearXNG URLs that include query parameters', async () => { + const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL; + + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com/search?format=json'; + + try { + const result = await hook.trySearxngSearch('btc price', 1); + expect(result.success).toBe(false); + expect(result.error).toContain('invalid or not configured'); + } finally { + if (originalUrl === undefined) { + delete process.env.CCS_WEBSEARCH_SEARXNG_URL; + } else { + process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl; + } + } + }); + + it('rejects credential-bearing SearXNG URLs', async () => { + const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL; + + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://user:pass@search.example.com'; + + try { + const result = await hook.trySearxngSearch('btc price', 1); + expect(result.success).toBe(false); + expect(result.error).toContain('invalid or not configured'); + } finally { + if (originalUrl === undefined) { + delete process.env.CCS_WEBSEARCH_SEARXNG_URL; + } else { + process.env.CCS_WEBSEARCH_SEARXNG_URL = originalUrl; + } + } + }); + it('marks SearXNG format-disabled 403 responses as non-retryable failures', async () => { const originalFetch = global.fetch; const originalUrl = process.env.CCS_WEBSEARCH_SEARXNG_URL; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index b00908fe..4152e09b 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -124,6 +124,20 @@ preferences: fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8'); } +function writeConfigWithWebSearchSettings(yamlBody: string): void { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-env-')); + process.env.CCS_HOME = tempHome; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const yaml = `version: 8 +preferences: + auto_update: true +websearch: +${yamlBody} +`; + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8'); +} + let execClaude: typeof import('../../../src/utils/shell-executor').execClaude; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; @@ -220,7 +234,7 @@ describe('CLAUDECODE environment stripping', () => { const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; expect(env).toBeDefined(); expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE'); - expect(env.CCS_WEBSEARCH_SKIP).toBe('1'); + expect(env.CCS_WEBSEARCH_ENABLED || env.CCS_WEBSEARCH_SKIP).toBeDefined(); }); it('execClaude keeps behavior when CLAUDECODE is absent', () => { @@ -262,6 +276,29 @@ describe('CLAUDECODE environment stripping', () => { expect(env.DISABLE_AUTOUPDATER).toBeUndefined(); }); + it('execClaude overrides stale inherited WebSearch provider flags with config-derived values', () => { + writeConfigWithWebSearchSettings(` enabled: true + providers: + duckduckgo: + enabled: true + searxng: + enabled: false + url: '' +`); + process.env.CCS_WEBSEARCH_SEARXNG = '1'; + process.env.CCS_WEBSEARCH_SEARXNG_URL = 'https://search.example.com'; + process.env.CCS_WEBSEARCH_SKIP = '1'; + + execClaude('claude', ['--version'], { CCS_PROFILE_TYPE: 'settings' }); + + expect(spawnCalls.length).toBeGreaterThan(0); + const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv; + expect(env.CCS_WEBSEARCH_ENABLED).toBe('1'); + expect(env.CCS_WEBSEARCH_SKIP).toBe('0'); + expect(env.CCS_WEBSEARCH_DUCKDUCKGO).toBe('1'); + expect(env.CCS_WEBSEARCH_SEARXNG).toBe('0'); + }); + it('execClaude normalizes shared plugin metadata before default-profile launch', () => { const normalizeSpy = spyOn( SharedManager.prototype, diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index a23ea719..3f15cf15 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -115,6 +115,60 @@ describe('websearch readiness', () => { expect(readiness.message).toContain('SearXNG'); }); + it('marks SearXNG as unavailable when config uses a query-bearing endpoint URL', () => { + const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { + enabled: true, + url: 'https://search.example.com/search?format=json', + max_results: 5, + }, + duckduckgo: { enabled: false, max_results: 5 }, + gemini: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + } as any); + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + + try { + const providers = getWebSearchCliProviders(); + const searxng = providers.find((entry) => entry.id === 'searxng'); + + expect(searxng?.enabled).toBe(true); + expect(searxng?.available).toBe(false); + expect(searxng?.detail).toContain('Set a valid SearXNG base URL'); + } finally { + getConfigSpy.mockRestore(); + apiKeySpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); + } + }); + it('treats cooled-down providers as temporarily unavailable in readiness status', () => { const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-')); const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json'); diff --git a/tests/unit/web-server/websearch-routes.test.ts b/tests/unit/web-server/websearch-routes.test.ts index 18908aff..d8ae5024 100644 --- a/tests/unit/web-server/websearch-routes.test.ts +++ b/tests/unit/web-server/websearch-routes.test.ts @@ -315,6 +315,86 @@ describe('websearch routes', () => { }); }); + it('normalizes searxng endpoint-style urls to the instance base URL', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + enabled: true, + url: 'https://search.example.com/custom/search/', + max_results: 5, + }, + }, + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.websearch.providers.searxng).toEqual({ + enabled: true, + url: 'https://search.example.com/custom', + max_results: 5, + }); + }); + + it('allows clearing a searxng url back to blank', async () => { + mutateUnifiedConfig((config) => { + config.websearch = { + enabled: true, + providers: { + ...config.websearch?.providers, + searxng: { + enabled: false, + url: 'https://search.example.com/custom', + max_results: 5, + }, + }, + }; + }); + + const response = await putWebsearch({ + providers: { + searxng: { + enabled: false, + url: '', + max_results: 5, + }, + }, + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.websearch.providers.searxng).toEqual({ + enabled: false, + url: '', + max_results: 5, + }); + }); + + it('sanitizes credential-bearing searxng urls out of the GET response', async () => { + mutateUnifiedConfig((config) => { + config.websearch = { + enabled: true, + providers: { + ...config.websearch?.providers, + searxng: { + enabled: true, + url: 'https://user:pass@search.example.com/search', + max_results: 5, + }, + }, + }; + }); + + const response = await fetch(`${baseUrl}/api/websearch`); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.providers.searxng).toEqual({ + enabled: true, + url: '', + max_results: 5, + }); + }); + it('rejects non-object request bodies', async () => { const response = await putWebsearch('[]'); @@ -400,6 +480,38 @@ describe('websearch routes', () => { }); }); + it('rejects searxng urls with query parameters', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + url: 'https://search.example.com/search?format=json', + }, + }, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: + 'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.', + }); + }); + + it('rejects credential-bearing searxng urls', async () => { + const response = await putWebsearch({ + providers: { + searxng: { + url: 'https://user:pass@search.example.com', + }, + }, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: + 'Invalid value for providers.searxng.url. Must be an http(s) base URL without credentials, query, or hash.', + }); + }); + it('rejects non-number searxng max_results values', async () => { const response = await putWebsearch({ providers: { diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 3f408749..b7a19a13 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -154,7 +154,8 @@ const BACKEND_PROVIDERS: ProviderDefinition[] = [ label: 'Base URL', type: 'text', placeholder: 'https://search.example.com', - helpText: 'Must expose /search with format=json enabled.', + helpText: + 'Paste the instance base URL only. CCS appends /search?format=json for you and rejects embedded credentials.', defaultValue: '', }, {