mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 08:02:14 +03:00
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:
@@ -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 {
|
||||
|
||||
81
tests/unit/search-baseurl-ssrf-guard.test.ts
Normal file
81
tests/unit/search-baseurl-ssrf-guard.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user