refactor(search): extract base-URL resolution into its own module

check:file-size flagged `open-sse/handlers/search.ts: 1824 > frozen 1789`. The
growth was mine — the GHSA-3f8g fix added the trust split plus the reasoning for
it to a file that was already at its ceiling.

Shrinking beats loosening the ratchet, and the gate's own message says so
("modularize/extraia (DRY) para encolher"), so the resolver moves to
`open-sse/handlers/search/baseUrl.ts` with its refusal error and the
single-source reader. search.ts drops 1824 -> 1772, under the ceiling, and the
base-URL trust decision — which source is operator, which is caller, which
providers may be redirected — is now one small unit instead of a passage inside
a 1800-line handler. search.ts re-exports both symbols, so every existing
importer is untouched.

Also drops the `parseAndValidateNonMetadataUrl` import that the extraction left
orphaned in search.ts; eslint on that file is back to exactly its 34 frozen
violations.

The remaining file-size failures (`open-sse/executors/codex.ts` 1503 > 1499,
`open-sse/utils/stream.ts` 3078 > 3072) are base debt in files this branch does
not touch.

search + baseUrl suites 52/52, typecheck:core exit 0, check:api-typecheck OK
(289, within baseline), check:cycles OK, check:mutation-test-coverage no drift.
This commit is contained in:
diegosouzapw
2026-09-03 14:06:43 -03:00
parent dfb3d434af
commit 17a1fe0bd2
2 changed files with 69 additions and 71 deletions

View File

@@ -20,6 +20,9 @@ import { randomUUID } from "crypto";
* }
*/
export { resolveSearchBaseUrl, SearchBaseUrlOverrideError } from "./search/baseUrl.ts";
import { resolveSearchBaseUrl } from "./search/baseUrl.ts";
import {
getSearchProvider,
isUnconfiguredLoopbackSearchProvider,
@@ -36,7 +39,6 @@ import * as anysearchSearch from "./search/anysearchSearch.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";
@@ -302,15 +304,6 @@ function parseDomainFilter(domainFilter?: string[]): {
return { includes, excludes };
}
/** Read one string setting from a SINGLE source, so callers can distinguish trust. */
function readProviderSettingString(
source: Record<string, unknown> | undefined,
key: string
): string | undefined {
const value = source?.[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function getProviderSettingString(
params: Pick<SearchRequestParams, "providerOptions" | "providerSpecificData">,
key: string
@@ -328,67 +321,6 @@ function getProviderSettingString(
return undefined;
}
export function resolveSearchBaseUrl(
config: SearchProviderConfig,
params: SearchRequestParams
): string {
// The two override sources are NOT equally trusted, and treating them as one
// was GHSA-3f8g-pfh9-j687.
//
// `providerSpecificData` is the stored provider connection (see the
// `credentials?.providerSpecificData` wiring below) — operator config. It is
// how an operator points at a self-hosted searxng, so loopback/LAN keeps
// working under the block-metadata policy: cloud-metadata endpoints (IMDS
// credential theft) stay rejected (GHSA-j7j4-g9qc-q69c).
const operatorOverride = readProviderSettingString(params.providerSpecificData, "baseUrl");
if (operatorOverride) {
parseAndValidateNonMetadataUrl(operatorOverride);
return operatorOverride.replace(/\/+$/, "");
}
// `providerOptions` is `body.provider_options` — straight off the request, so
// tenant input. It is refused outright rather than validated:
//
// - For a keyed provider the builder attaches the OPERATOR's API key to
// whatever host this resolves to (`key=`/`api_key=` in the query for
// google-pse/searchapi, `X-API-Key`/`Authorization` for you.com/linkup/
// nimble/ollama), so honoring a caller-chosen host hands the operator's
// third-party key to that host. A block-metadata check does nothing about
// it — the attacker just uses their own public host.
// - For any provider the response body is parsed and returned to the caller,
// so a caller-chosen target is SSRF with readback.
//
// Only a provider that is BOTH keyless and explicitly opted in
// (`allowClientBaseUrlOverride`) may be redirected by its caller. Today that
// is SearXNG alone, whose self-hosted flow is documented and tested.
const callerOverride = readProviderSettingString(params.providerOptions, "baseUrl");
if (callerOverride) {
if (!config.allowClientBaseUrlOverride || config.authType === "apikey") {
throw new SearchBaseUrlOverrideError(config.id);
}
// Opted-in keyless provider (self-hosted SearXNG): no operator credential
// travels with the request, so the remaining exposure is the fetch target
// itself. Block-metadata, matching the operator path — loopback/LAN is the
// whole point of a self-hosted instance, IMDS is not.
parseAndValidateNonMetadataUrl(callerOverride);
return callerOverride.replace(/\/+$/, "");
}
return config.baseUrl.replace(/\/+$/, "");
}
/** Refusal for a caller-supplied `provider_options.baseUrl` (GHSA-3f8g-pfh9-j687). */
export class SearchBaseUrlOverrideError extends Error {
readonly code = "SEARCH_BASE_URL_OVERRIDE_REFUSED";
constructor(providerId: string) {
super(
`provider_options.baseUrl is not accepted for search provider "${providerId}". ` +
`The base URL is operator configuration; set it on the provider connection instead.`
);
this.name = "SearchBaseUrlOverrideError";
}
}
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {
if (typeof offset !== "number" || offset <= 0 || maxResults <= 0) return undefined;
return Math.floor(offset / maxResults) + 1;

View File

@@ -0,0 +1,66 @@
/**
* Base-URL resolution for /v1/search — the trust decision, on its own.
*
* The two override sources are NOT equally trusted, and reading them through
* one call is what produced GHSA-3f8g-pfh9-j687:
*
* - `providerSpecificData` is the stored provider connection
* (`credentials?.providerSpecificData`) — OPERATOR config. Honored under
* block-metadata, so a self-hosted SearXNG on loopback/LAN keeps working
* while cloud metadata (IMDS credential theft) stays rejected (GHSA-j7j4).
* - `providerOptions` is `body.provider_options` — CALLER input. Honored only
* by a provider that is keyless AND opted in via
* `allowClientBaseUrlOverride`. A keyed builder attaches the OPERATOR's key
* to whatever host this resolves to (`key=`/`api_key=` in the query for
* google-pse/searchapi, `X-API-Key`/`Authorization` for
* you.com/linkup/nimble/ollama), so a caller-chosen host would collect it —
* and a block-metadata check does nothing about that, because the attacker
* simply names their own public host.
*
* Full coverage: tests/unit/search-baseurl-client-override-3f8g.test.ts.
*/
import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard";
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
interface BaseUrlParams {
providerOptions?: Record<string, unknown>;
providerSpecificData?: Record<string, unknown>;
}
/** Read one string setting from a SINGLE source, so callers can distinguish trust. */
function readSetting(source: Record<string, unknown> | undefined, key: string): string | undefined {
const value = source?.[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
/** Refusal for a caller-supplied `provider_options.baseUrl` (GHSA-3f8g-pfh9-j687). */
export class SearchBaseUrlOverrideError extends Error {
readonly code = "SEARCH_BASE_URL_OVERRIDE_REFUSED";
constructor(providerId: string) {
super(
`provider_options.baseUrl is not accepted for search provider "${providerId}". ` +
`Set the base URL on the provider connection instead.`
);
this.name = "SearchBaseUrlOverrideError";
}
}
export function resolveSearchBaseUrl(config: SearchProviderConfig, params: BaseUrlParams): string {
const operatorOverride = readSetting(params.providerSpecificData, "baseUrl");
if (operatorOverride) {
parseAndValidateNonMetadataUrl(operatorOverride);
return operatorOverride.replace(/\/+$/, "");
}
const callerOverride = readSetting(params.providerOptions, "baseUrl");
if (callerOverride) {
if (!config.allowClientBaseUrlOverride || config.authType === "apikey") {
throw new SearchBaseUrlOverrideError(config.id);
}
parseAndValidateNonMetadataUrl(callerOverride);
return callerOverride.replace(/\/+$/, "");
}
return config.baseUrl.replace(/\/+$/, "");
}