diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index 2f138b09d1..e68603e459 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -20,6 +20,9 @@ import { randomUUID } from "crypto"; * } */ +export { resolveSearchBaseUrl, SearchBaseUrlOverrideError } from "./search/baseUrl.ts"; +import { resolveSearchBaseUrl } from "./search/baseUrl.ts"; + import { getSearchProvider, isUnconfiguredLoopbackSearchProvider, @@ -36,7 +39,6 @@ import * as anysearchSearch from "./search/anysearchSearch.ts"; import { freeWebSearch } from "../services/freeWebSearch.ts"; import { saveCallLog } from "@/lib/usageDb"; import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { z } from "zod"; @@ -302,15 +304,6 @@ function parseDomainFilter(domainFilter?: string[]): { return { includes, excludes }; } -/** Read one string setting from a SINGLE source, so callers can distinguish trust. */ -function readProviderSettingString( - source: Record | undefined, - key: string -): string | undefined { - const value = source?.[key]; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function getProviderSettingString( params: Pick, key: string @@ -328,67 +321,6 @@ function getProviderSettingString( return undefined; } -export function resolveSearchBaseUrl( - config: SearchProviderConfig, - params: SearchRequestParams -): string { - // The two override sources are NOT equally trusted, and treating them as one - // was GHSA-3f8g-pfh9-j687. - // - // `providerSpecificData` is the stored provider connection (see the - // `credentials?.providerSpecificData` wiring below) — operator config. It is - // how an operator points at a self-hosted searxng, so loopback/LAN keeps - // working under the block-metadata policy: cloud-metadata endpoints (IMDS - // credential theft) stay rejected (GHSA-j7j4-g9qc-q69c). - const operatorOverride = readProviderSettingString(params.providerSpecificData, "baseUrl"); - if (operatorOverride) { - parseAndValidateNonMetadataUrl(operatorOverride); - return operatorOverride.replace(/\/+$/, ""); - } - - // `providerOptions` is `body.provider_options` — straight off the request, so - // tenant input. It is refused outright rather than validated: - // - // - For a keyed provider the builder attaches the OPERATOR's API key to - // whatever host this resolves to (`key=`/`api_key=` in the query for - // google-pse/searchapi, `X-API-Key`/`Authorization` for you.com/linkup/ - // nimble/ollama), so honoring a caller-chosen host hands the operator's - // third-party key to that host. A block-metadata check does nothing about - // it — the attacker just uses their own public host. - // - For any provider the response body is parsed and returned to the caller, - // so a caller-chosen target is SSRF with readback. - // - // Only a provider that is BOTH keyless and explicitly opted in - // (`allowClientBaseUrlOverride`) may be redirected by its caller. Today that - // is SearXNG alone, whose self-hosted flow is documented and tested. - const callerOverride = readProviderSettingString(params.providerOptions, "baseUrl"); - if (callerOverride) { - if (!config.allowClientBaseUrlOverride || config.authType === "apikey") { - throw new SearchBaseUrlOverrideError(config.id); - } - // Opted-in keyless provider (self-hosted SearXNG): no operator credential - // travels with the request, so the remaining exposure is the fetch target - // itself. Block-metadata, matching the operator path — loopback/LAN is the - // whole point of a self-hosted instance, IMDS is not. - parseAndValidateNonMetadataUrl(callerOverride); - return callerOverride.replace(/\/+$/, ""); - } - - return config.baseUrl.replace(/\/+$/, ""); -} - -/** Refusal for a caller-supplied `provider_options.baseUrl` (GHSA-3f8g-pfh9-j687). */ -export class SearchBaseUrlOverrideError extends Error { - readonly code = "SEARCH_BASE_URL_OVERRIDE_REFUSED"; - constructor(providerId: string) { - super( - `provider_options.baseUrl is not accepted for search provider "${providerId}". ` + - `The base URL is operator configuration; set it on the provider connection instead.` - ); - this.name = "SearchBaseUrlOverrideError"; - } -} - function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined { if (typeof offset !== "number" || offset <= 0 || maxResults <= 0) return undefined; return Math.floor(offset / maxResults) + 1; diff --git a/open-sse/handlers/search/baseUrl.ts b/open-sse/handlers/search/baseUrl.ts new file mode 100644 index 0000000000..ba1e1fe6b0 --- /dev/null +++ b/open-sse/handlers/search/baseUrl.ts @@ -0,0 +1,66 @@ +/** + * Base-URL resolution for /v1/search — the trust decision, on its own. + * + * The two override sources are NOT equally trusted, and reading them through + * one call is what produced GHSA-3f8g-pfh9-j687: + * + * - `providerSpecificData` is the stored provider connection + * (`credentials?.providerSpecificData`) — OPERATOR config. Honored under + * block-metadata, so a self-hosted SearXNG on loopback/LAN keeps working + * while cloud metadata (IMDS credential theft) stays rejected (GHSA-j7j4). + * - `providerOptions` is `body.provider_options` — CALLER input. Honored only + * by a provider that is keyless AND opted in via + * `allowClientBaseUrlOverride`. A keyed builder attaches the OPERATOR's key + * to whatever host this resolves to (`key=`/`api_key=` in the query for + * google-pse/searchapi, `X-API-Key`/`Authorization` for + * you.com/linkup/nimble/ollama), so a caller-chosen host would collect it — + * and a block-metadata check does nothing about that, because the attacker + * simply names their own public host. + * + * Full coverage: tests/unit/search-baseurl-client-override-3f8g.test.ts. + */ + +import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard"; +import type { SearchProviderConfig } from "../../config/searchRegistry.ts"; + +interface BaseUrlParams { + providerOptions?: Record; + providerSpecificData?: Record; +} + +/** Read one string setting from a SINGLE source, so callers can distinguish trust. */ +function readSetting(source: Record | undefined, key: string): string | undefined { + const value = source?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** Refusal for a caller-supplied `provider_options.baseUrl` (GHSA-3f8g-pfh9-j687). */ +export class SearchBaseUrlOverrideError extends Error { + readonly code = "SEARCH_BASE_URL_OVERRIDE_REFUSED"; + constructor(providerId: string) { + super( + `provider_options.baseUrl is not accepted for search provider "${providerId}". ` + + `Set the base URL on the provider connection instead.` + ); + this.name = "SearchBaseUrlOverrideError"; + } +} + +export function resolveSearchBaseUrl(config: SearchProviderConfig, params: BaseUrlParams): string { + const operatorOverride = readSetting(params.providerSpecificData, "baseUrl"); + if (operatorOverride) { + parseAndValidateNonMetadataUrl(operatorOverride); + return operatorOverride.replace(/\/+$/, ""); + } + + const callerOverride = readSetting(params.providerOptions, "baseUrl"); + if (callerOverride) { + if (!config.allowClientBaseUrlOverride || config.authType === "apikey") { + throw new SearchBaseUrlOverrideError(config.id); + } + parseAndValidateNonMetadataUrl(callerOverride); + return callerOverride.replace(/\/+$/, ""); + } + + return config.baseUrl.replace(/\/+$/, ""); +}