fix(security): SSRF guard on client-controlled search baseUrl (GHSA-j7j4-g9qc-q69c)

/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.
This commit is contained in:
Xiangzhe
2026-08-23 12:48:02 -03:00
parent e1fdfc40a5
commit 81cc000cf5
2 changed files with 98 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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");
});
});