From 3443c93437c78ac8a5c97eb54c2f055c82dd010f Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 19 Aug 2026 08:44:55 -0300 Subject: [PATCH] fix(security): block SSRF via /v1/search Firecrawl provider_options.baseUrl The client-controlled provider_options.baseUrl (and legacy top-level baseUrl) override was used verbatim to build the server-side fetch target in buildFirecrawlSearchRequest(), with no SSRF validation. A caller with a valid API key could redirect the search request at an internal host (loopback, RFC1918, or a cloud-metadata endpoint) and read the response back through the normal search result shape. Validate the override with the existing outboundUrlGuard (parseAndValidatePublicUrl) before it is used to build the fetch URL. jinaSearch and perplexitySearch were checked and do not accept a client-controlled baseUrl, so only firecrawlSearch needed the guard. Reported-by: zmf963 --- CHANGELOG.md | 1 + open-sse/handlers/search/firecrawlSearch.ts | 7 ++ .../unit/firecrawl-search-ssrf-guard.test.ts | 81 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 tests/unit/firecrawl-search-ssrf-guard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 49668a6214bb..94438a092287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e ### 🐛 Bug Fixes +- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963 - **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366) - **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding) - test(combo): guard auto/best-free never leaks the combo name as a model (#7754) diff --git a/open-sse/handlers/search/firecrawlSearch.ts b/open-sse/handlers/search/firecrawlSearch.ts index 9885486e944d..87c33823f094 100644 --- a/open-sse/handlers/search/firecrawlSearch.ts +++ b/open-sse/handlers/search/firecrawlSearch.ts @@ -1,4 +1,5 @@ import type { SearchProviderConfig } from "../../config/searchRegistry.ts"; +import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard"; export interface FirecrawlSearchParams { query: string; @@ -74,6 +75,12 @@ export function buildFirecrawlSearchRequest( const paramBase = typeof params.baseUrl === "string" ? params.baseUrl : providerData?.baseUrl; const customBase = typeof paramBase === "string" && paramBase.trim() ? paramBase.trim().replace(/\/+$/, "") : undefined; const rawBase = envBase || customBase; + // #3049: `customBase` (params.baseUrl / providerSpecificData.baseUrl) is client-controlled — + // validate it as a public URL before it is used to build the server-side fetch target, so a + // caller cannot redirect the search request at loopback, RFC1918, or cloud-metadata hosts. + if (customBase) { + parseAndValidatePublicUrl(customBase); + } const url = rawBase ? `${rawBase}/v2/search` : config.baseUrl; const { includes, excludes } = parseDomainFilter(params.domainFilter); const source = params.searchType === "news" ? "news" : "web"; diff --git a/tests/unit/firecrawl-search-ssrf-guard.test.ts b/tests/unit/firecrawl-search-ssrf-guard.test.ts new file mode 100644 index 000000000000..c4fe68c4f713 --- /dev/null +++ b/tests/unit/firecrawl-search-ssrf-guard.test.ts @@ -0,0 +1,81 @@ +/** + * SSRF guard coverage for /v1/search's Firecrawl provider. + * + * `provider_options.baseUrl` (and the legacy top-level `baseUrl` field) is + * client-controlled and was used verbatim to build the server-side fetch + * target in `buildFirecrawlSearchRequest()`, with no SSRF validation. A + * caller with a valid API key could redirect the search request to an + * internal host (loopback, RFC1918, or a cloud metadata endpoint) and read + * the response back through the normal search result shape. + * + * Run with: + * node --import tsx/esm --test tests/unit/firecrawl-search-ssrf-guard.test.ts + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { buildFirecrawlSearchRequest } from "../../open-sse/handlers/search/firecrawlSearch.ts"; +import type { SearchProviderConfig } from "../../open-sse/config/searchRegistry.ts"; + +const config: SearchProviderConfig = { + id: "firecrawl", + name: "Firecrawl", + baseUrl: "https://api.firecrawl.dev/v2/search", + method: "POST", + authType: "apikey", + authHeader: "Authorization", + costPerQuery: 0, +} as SearchProviderConfig; + +const MALICIOUS_BASE_URLS = [ + "http://127.0.0.1:22", + "http://169.254.169.254/latest/meta-data/", // AWS IMDS + "http://10.0.0.5:6379", + "http://localhost:20128/api/admin", +]; + +describe("buildFirecrawlSearchRequest — SSRF guard on client-controlled baseUrl", () => { + for (const maliciousBase of MALICIOUS_BASE_URLS) { + it(`rejects provider_options.baseUrl pointing at ${maliciousBase}`, () => { + assert.throws(() => { + buildFirecrawlSearchRequest(config, { + query: "test", + searchType: "web", + maxResults: 5, + providerSpecificData: { baseUrl: maliciousBase }, + }); + }); + }); + + it(`rejects top-level baseUrl pointing at ${maliciousBase}`, () => { + assert.throws(() => { + buildFirecrawlSearchRequest(config, { + query: "test", + searchType: "web", + maxResults: 5, + baseUrl: maliciousBase, + }); + }); + }); + } + + it("still allows the default public Firecrawl base URL", () => { + const { url } = buildFirecrawlSearchRequest(config, { + query: "test", + searchType: "web", + maxResults: 5, + }); + assert.equal(url, config.baseUrl); + }); + + it("still allows an explicit public https baseUrl override", () => { + const { url } = buildFirecrawlSearchRequest(config, { + query: "test", + searchType: "web", + maxResults: 5, + providerSpecificData: { baseUrl: "https://self-hosted.example.com" }, + }); + assert.equal(url, "https://self-hosted.example.com/v2/search"); + }); +});