Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions open-sse/handlers/search/firecrawlSearch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard";

export interface FirecrawlSearchParams {
query: string;
Expand Down Expand Up @@ -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";
Expand Down
81 changes: 81 additions & 0 deletions tests/unit/firecrawl-search-ssrf-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading