From 81cc000cf5f08ca8c2d451bacea6cbff2799ec1f Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Sun, 23 Aug 2026 12:48:02 -0300 Subject: [PATCH] fix(security): SSRF guard on client-controlled search baseUrl (GHSA-j7j4-g9qc-q69c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v1/search accepted provider_options.baseUrl / providerSpecificData.baseUrl verbatim and flowed it through resolveSearchBaseUrl() into every builder's server-side fetch target, while the sink (searchProxy.ts) is a plain fetch(). The Firecrawl sibling was fixed in #10738; this shared resolver was missed — full-read SSRF to cloud metadata (IMDS credential theft) and JSON-speaking internal services, reachable with no credentials on the default posture. resolveSearchBaseUrl() now validates any request-supplied override with parseAndValidateNonMetadataUrl (block-metadata): self-hosted searxng on loopback/LAN — the provider's primary use case — keeps working, while cloud-metadata endpoints are rejected. The catalog's operator-configured baseUrl stays untouched. --- open-sse/handlers/search.ts | 19 ++++- tests/unit/search-baseurl-ssrf-guard.test.ts | 81 ++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/unit/search-baseurl-ssrf-guard.test.ts diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index d5cbc5fa38..858ea6f68a 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -31,6 +31,7 @@ import * as xSearch from "./search/xSearch.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"; @@ -313,9 +314,23 @@ function getProviderSettingString( return undefined; } -function resolveSearchBaseUrl(config: SearchProviderConfig, params: SearchRequestParams): string { +export function resolveSearchBaseUrl( + config: SearchProviderConfig, + params: SearchRequestParams +): string { const override = getProviderSettingString(params, "baseUrl"); - return (override || config.baseUrl).replace(/\/+$/, ""); + if (override) { + // GHSA-j7j4-g9qc-q69c: the override is client-controlled (provider_options / + // providerSpecificData) and flows into a plain fetch() sink — validate it + // before any builder uses it as the server-side fetch target. Mode is + // block-metadata (NOT public-only): the primary searxng use case is a + // self-hosted instance on loopback/LAN, so private hosts keep working, + // while cloud-metadata endpoints (IMDS credential theft) are rejected. + // The catalog's own config.baseUrl is operator config and stays untouched. + parseAndValidateNonMetadataUrl(override); + return override.replace(/\/+$/, ""); + } + return config.baseUrl.replace(/\/+$/, ""); } function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined { diff --git a/tests/unit/search-baseurl-ssrf-guard.test.ts b/tests/unit/search-baseurl-ssrf-guard.test.ts new file mode 100644 index 0000000000..f42335edd3 --- /dev/null +++ b/tests/unit/search-baseurl-ssrf-guard.test.ts @@ -0,0 +1,81 @@ +/** + * SSRF guard coverage for /v1/search's shared base-url resolution (GHSA-j7j4-g9qc-q69c). + * + * `provider_options.baseUrl` (and legacy `providerSpecificData.baseUrl`) is + * client-controlled and flowed verbatim through `resolveSearchBaseUrl()` into + * every search builder's server-side fetch target (searxng, ollama, …), with + * no SSRF validation — while the sink (`searchProxy.ts`) is a plain `fetch()`. + * The Firecrawl sibling was fixed in #10738; this shared resolver was missed. + * + * Guard mode is `block-metadata` (NOT public-only): the catalog's primary + * searxng use case is a self-hosted instance on loopback/LAN, so private + * hosts must keep working, while cloud-metadata endpoints (IMDS credential + * theft — the worst pivot) are rejected. + * + * Run with: + * node --import tsx/esm --test tests/unit/search-baseurl-ssrf-guard.test.ts + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveSearchBaseUrl } from "../../open-sse/handlers/search.ts"; +import type { SearchProviderConfig } from "../../open-sse/config/searchRegistry.ts"; + +const config: SearchProviderConfig = { + id: "searxng-search", + name: "SearXNG", + baseUrl: "http://127.0.0.1:8888", + method: "GET", + authType: "none", + costPerQuery: 0, +} as SearchProviderConfig; + +const base = { + query: "test", + searchType: "web", + maxResults: 5, +}; + +const METADATA_URLS = [ + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "http://169.254.169.254/latest/meta-data/?x=/search", // reporter's suffix-bypass shape + "http://metadata.google.internal/computeMetadata/v1/", +]; + +describe("resolveSearchBaseUrl — SSRF guard on client-controlled baseUrl (GHSA-j7j4)", () => { + for (const malicious of METADATA_URLS) { + it(`rejects providerOptions.baseUrl pointing at cloud metadata (${malicious})`, () => { + assert.throws(() => { + resolveSearchBaseUrl(config, { ...base, providerOptions: { baseUrl: malicious } }); + }); + }); + + it(`rejects providerSpecificData.baseUrl pointing at cloud metadata (${malicious})`, () => { + assert.throws(() => { + resolveSearchBaseUrl(config, { ...base, providerSpecificData: { baseUrl: malicious } }); + }); + }); + } + + it("still allows a self-hosted loopback/LAN override (block-metadata, not public-only)", () => { + assert.equal( + resolveSearchBaseUrl(config, { + ...base, + providerOptions: { baseUrl: "http://127.0.0.1:9999" }, + }), + "http://127.0.0.1:9999" + ); + assert.equal( + resolveSearchBaseUrl(config, { + ...base, + providerOptions: { baseUrl: "http://10.0.0.5:8080" }, + }), + "http://10.0.0.5:8080" + ); + }); + + it("leaves the catalog baseUrl untouched when no override is supplied", () => { + assert.equal(resolveSearchBaseUrl(config, base), "http://127.0.0.1:8888"); + }); +});