
























@@ -14,11 +14,18 @@ import {
1414resolveSearchCount,
1515resolveSearchTimeoutSeconds,
1616resolveSiteName,
17+withSelfHostedWebSearchEndpoint,
1718withTrustedWebSearchEndpoint,
1819wrapWebContent,
1920writeCachedSearchPayload,
2021} from "openclaw/plugin-sdk/provider-web-search";
2122import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
23+import {
24+assertHttpUrlTargetsPrivateNetwork,
25+isBlockedHostnameOrIp,
26+isPrivateIpAddress,
27+resolvePinnedHostnameWithPolicy,
28+} from "openclaw/plugin-sdk/ssrf-runtime";
2229import {
2330type BraveLlmContextResponse,
2431mapBraveLlmContextResults,
@@ -28,9 +35,11 @@ import {
2835resolveBraveMode,
2936} from "./brave-web-search-provider.shared.js";
303731-const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
32-const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
38+const DEFAULT_BRAVE_BASE_URL = "https://api.search.brave.com";
39+const BRAVE_SEARCH_ENDPOINT_PATH = "/res/v1/web/search";
40+const BRAVE_LLM_CONTEXT_ENDPOINT_PATH = "/res/v1/llm/context";
3341const braveHttpLogger = createSubsystemLogger("brave/http");
42+type BraveEndpointMode = "selfHosted" | "strict";
34433544type BraveSearchResult = {
3645title?: string;
@@ -79,6 +88,63 @@ function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefin
7988);
8089}
819091+function resolveBraveBaseUrl(braveConfig: { baseUrl?: unknown } | undefined): string {
92+const configured = readConfiguredSecretString(
93+braveConfig?.baseUrl,
94+"plugins.entries.brave.config.webSearch.baseUrl",
95+);
96+return configured?.replace(/\/+$/u, "") || DEFAULT_BRAVE_BASE_URL;
97+}
98+99+function buildBraveEndpointUrl(params: { baseUrl: string; endpointPath: string }): URL {
100+const url = new URL(params.baseUrl);
101+const basePath = url.pathname.replace(/\/+$/u, "");
102+url.pathname = `${basePath}${params.endpointPath}`;
103+url.search = "";
104+return url;
105+}
106+107+async function braveEndpointTargetsPrivateNetwork(url: URL): Promise<boolean> {
108+if (isBlockedHostnameOrIp(url.hostname)) {
109+return true;
110+}
111+try {
112+const pinned = await resolvePinnedHostnameWithPolicy(url.hostname, {
113+policy: {
114+allowPrivateNetwork: true,
115+allowRfc2544BenchmarkRange: true,
116+},
117+});
118+return pinned.addresses.every((address) => isPrivateIpAddress(address));
119+} catch {
120+return false;
121+}
122+}
123+124+async function validateBraveBaseUrl(baseUrl: string): Promise<BraveEndpointMode> {
125+let parsed: URL;
126+try {
127+parsed = new URL(baseUrl);
128+} catch {
129+throw new Error("Brave Search base URL must be a valid http:// or https:// URL.");
130+}
131+132+if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
133+throw new Error("Brave Search base URL must use http:// or https://.");
134+}
135+136+if (parsed.protocol === "http:") {
137+await assertHttpUrlTargetsPrivateNetwork(parsed.toString(), {
138+dangerouslyAllowPrivateNetwork: true,
139+errorMessage:
140+"Brave Search HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
141+});
142+return "selfHosted";
143+}
144+145+return (await braveEndpointTargetsPrivateNetwork(parsed)) ? "selfHosted" : "strict";
146+}
147+82148function missingBraveKeyPayload() {
83149return {
84150error: "missing_brave_api_key",
@@ -88,6 +154,8 @@ function missingBraveKeyPayload() {
88154}
8915590156async function runBraveLlmContextSearch(params: {
157+baseUrl: string;
158+endpointMode: BraveEndpointMode;
91159query: string;
92160apiKey: string;
93161timeoutSeconds: number;
@@ -106,7 +174,10 @@ async function runBraveLlmContextSearch(params: {
106174}>;
107175sources?: BraveLlmContextResponse["sources"];
108176}> {
109-const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
177+const url = buildBraveEndpointUrl({
178+baseUrl: params.baseUrl,
179+endpointPath: BRAVE_LLM_CONTEXT_ENDPOINT_PATH,
180+});
110181url.searchParams.set("q", params.query);
111182if (params.country) {
112183url.searchParams.set("country", params.country);
@@ -130,7 +201,11 @@ async function runBraveLlmContextSearch(params: {
130201 ...describeBraveRequestUrl(url),
131202});
132203const startedAt = Date.now();
133-return withTrustedWebSearchEndpoint(
204+const withEndpoint =
205+params.endpointMode === "selfHosted"
206+ ? withSelfHostedWebSearchEndpoint
207+ : withTrustedWebSearchEndpoint;
208+return withEndpoint(
134209{
135210url: url.toString(),
136211timeoutSeconds: params.timeoutSeconds,
@@ -163,6 +238,8 @@ async function runBraveLlmContextSearch(params: {
163238}
164239165240async function runBraveWebSearch(params: {
241+baseUrl: string;
242+endpointMode: BraveEndpointMode;
166243query: string;
167244count: number;
168245apiKey: string;
@@ -175,7 +252,10 @@ async function runBraveWebSearch(params: {
175252dateAfter?: string;
176253dateBefore?: string;
177254}): Promise<Array<Record<string, unknown>>> {
178-const url = new URL(BRAVE_SEARCH_ENDPOINT);
255+const url = buildBraveEndpointUrl({
256+baseUrl: params.baseUrl,
257+endpointPath: BRAVE_SEARCH_ENDPOINT_PATH,
258+});
179259url.searchParams.set("q", params.query);
180260url.searchParams.set("count", String(params.count));
181261if (params.country) {
@@ -205,7 +285,11 @@ async function runBraveWebSearch(params: {
205285 ...describeBraveRequestUrl(url),
206286});
207287const startedAt = Date.now();
208-return withTrustedWebSearchEndpoint(
288+const withEndpoint =
289+params.endpointMode === "selfHosted"
290+ ? withSelfHostedWebSearchEndpoint
291+ : withTrustedWebSearchEndpoint;
292+return withEndpoint(
209293{
210294url: url.toString(),
211295timeoutSeconds: params.timeoutSeconds,
@@ -263,6 +347,8 @@ export async function executeBraveSearch(
263347264348const braveConfig = resolveBraveConfig(searchConfig);
265349const braveMode = resolveBraveMode(braveConfig);
350+const braveBaseUrl = resolveBraveBaseUrl(braveConfig);
351+const braveEndpointMode = await validateBraveBaseUrl(braveBaseUrl);
266352const query = readStringParam(args, "query", { required: true });
267353const count =
268354readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
@@ -358,6 +444,7 @@ export async function executeBraveSearch(
358444 ? [
359445"brave",
360446braveMode,
447+braveBaseUrl,
361448query,
362449country,
363450normalizedLanguage.search_lang,
@@ -368,6 +455,7 @@ export async function executeBraveSearch(
368455 : [
369456"brave",
370457braveMode,
458+braveBaseUrl,
371459query,
372460resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
373461country,
@@ -392,6 +480,8 @@ export async function executeBraveSearch(
392480393481if (braveMode === "llm-context") {
394482const { results, sources } = await runBraveLlmContextSearch({
483+baseUrl: braveBaseUrl,
484+endpointMode: braveEndpointMode,
395485 query,
396486 apiKey,
397487 timeoutSeconds,
@@ -434,6 +524,8 @@ export async function executeBraveSearch(
434524}
435525436526const results = await runBraveWebSearch({
527+baseUrl: braveBaseUrl,
528+endpointMode: braveEndpointMode,
437529 query,
438530count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
439531 apiKey,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。