mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
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:
@@ -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;
|
||||
|
||||
66
open-sse/handlers/search/baseUrl.ts
Normal file
66
open-sse/handlers/search/baseUrl.ts
Normal 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(/\/+$/, "");
|
||||
}
|
||||
Reference in New Issue
Block a user