diff --git a/changelog.d/fixes/6925-embeddings-lan-noauth.md b/changelog.d/fixes/6925-embeddings-lan-noauth.md new file mode 100644 index 0000000000..03bd51b6d1 --- /dev/null +++ b/changelog.d/fixes/6925-embeddings-lan-noauth.md @@ -0,0 +1 @@ +- fix(sse): classify LAN embeddings providers (10/8, 192.168/16, CGNAT) as no-auth instead of forcing bearer auth (#6925) diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index 49b16f9db2..19c5da6fa6 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -17,6 +17,7 @@ import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; import { resolveBareModelToConnectionDefault } from "@omniroute/open-sse/services/model.ts"; import { findEmbeddingComboDimensionConflict } from "./familyGuard"; +import { isPrivateHost, isCloudMetadataHost } from "@/shared/network/outboundUrlGuard"; import { calculateCost } from "@/lib/usage/costCalculator"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; @@ -24,6 +25,17 @@ import { generateRequestId } from "@/shared/utils/requestId"; type ValidatedEmbeddingBody = Record & { model: string }; type ProviderCredentialsResult = Awaited>; +// #6925: a private/LAN host (RFC1918 10/8, 192.168/16, 172.16/12, CGNAT 100.64/10, +// loopback, .local/.internal, ULA/link-local IPv6) is treated as a trusted no-auth +// local embedding provider — mirrors the outbound-URL guard's private-host +// classification instead of the old hand-rolled localhost/127.0.0.1/172.16-31-only +// regex, which excluded common LAN ranges (10.x, 192.168.x) and forced them through +// the apikey/bearer fallback even when no credentials exist. Cloud-metadata hosts +// (169.254.169.254 etc.) are never treated as no-auth local providers. +function isNoAuthLocalEmbeddingHost(hostname: string): boolean { + return isPrivateHost(hostname) && !isCloudMetadataHost(hostname); +} + export interface EmbeddingHandlerOptions { clientRawRequest?: { endpoint: string; @@ -119,11 +131,7 @@ export async function createEmbeddingResponse( if (!validTypes.includes(n.apiType || "")) return false; try { const hostname = new URL(n.baseUrl).hostname; - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) - ); + return isNoAuthLocalEmbeddingHost(hostname); } catch { return false; } @@ -164,11 +172,22 @@ export async function createEmbeddingResponse( ); if (matchingNode) { const baseUrl = String(matchingNode.baseUrl).replace(/\/+$/, ""); + // #6925: a private/LAN node reaching this fallback (e.g. a matching + // prefix that skipped the dynamicProviders pass above) must never be + // forced through bearer-auth — only a non-private host falls back to + // apikey/bearer credential resolution. + let nodeHostname = ""; + try { + nodeHostname = new URL(matchingNode.baseUrl).hostname; + } catch { + nodeHostname = ""; + } + const isNoAuthLocal = nodeHostname !== "" && isNoAuthLocalEmbeddingHost(nodeHostname); providerConfig = { id: matchingNode.prefix, baseUrl: `${baseUrl}/embeddings`, - authType: "apikey", - authHeader: "bearer", + authType: isNoAuthLocal ? "none" : "apikey", + authHeader: isNoAuthLocal ? "none" : "bearer", models: [], }; credentialsProviderId = matchingNode.id || provider; diff --git a/tests/unit/embeddings-lan-noauth-6925.test.ts b/tests/unit/embeddings-lan-noauth-6925.test.ts new file mode 100644 index 0000000000..e2e07e5c0d --- /dev/null +++ b/tests/unit/embeddings-lan-noauth-6925.test.ts @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Isolate the DB to a temp dir BEFORE importing any module that opens it. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-embed-lan-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { createProviderNode } = await import("../../src/lib/db/providers/nodes.ts"); +const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// #6925: a keyless LAN OpenAI-compatible embeddings provider (e.g. Ollama at +// 10.x/192.168.x) must be classified as a no-auth local provider — never +// forced through the apikey/bearer fallback (which returns 401 because no +// credentials exist for a provider that was never meant to need any). +test("#6925: 10.x LAN embeddings provider is treated as no-auth (no Authorization header, no 401)", async () => { + await createProviderNode({ + type: "openai-compatible-embeddings", + name: "LAN Ollama", + prefix: "lanollama6925", + apiType: "embeddings", + baseUrl: "http://10.10.0.181:11434/v1", + }); + + const originalFetch = globalThis.fetch; + let captured: { url: string; headers: Record } | null = null; + globalThis.fetch = async (url: RequestInfo | URL, options: RequestInit = {}) => { + captured = { + url: String(url), + headers: (options.headers as Record) || {}, + }; + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 3, total_tokens: 3 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const res = await createEmbeddingResponse({ + model: "lanollama6925/nomic-embed-text", + input: "hello world", + }); + assert.equal(res.status, 200, "LAN embeddings request should succeed, not be blocked by auth"); + } finally { + globalThis.fetch = originalFetch; + } + + assert.ok(captured, "upstream fetch should have been called"); + assert.equal( + captured!.url, + "http://10.10.0.181:11434/v1/embeddings", + "should hit the LAN provider's own embeddings endpoint" + ); + assert.equal( + captured!.headers.Authorization, + undefined, + "a keyless LAN provider must not receive a fabricated Authorization header" + ); +}); + +test("#6925: 192.168.x LAN embeddings provider is also treated as no-auth", async () => { + await createProviderNode({ + type: "openai-compatible-embeddings", + name: "LAN Ollama 192", + prefix: "lanollama6925b", + apiType: "embeddings", + baseUrl: "http://192.168.1.10:11434/v1", + }); + + const originalFetch = globalThis.fetch; + let captured: { headers: Record } | null = null; + globalThis.fetch = async (_url: RequestInfo | URL, options: RequestInit = {}) => { + captured = { headers: (options.headers as Record) || {} }; + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 3, total_tokens: 3 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const res = await createEmbeddingResponse({ + model: "lanollama6925b/nomic-embed-text", + input: "hello world", + }); + assert.equal(res.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + + assert.ok(captured); + assert.equal(captured!.headers.Authorization, undefined); +}); + +test("#6925: cloud-metadata endpoint (169.254.169.254) stays blocked, does not become a bare no-auth provider", async () => { + await createProviderNode({ + type: "openai-compatible-embeddings", + name: "Metadata Probe", + prefix: "metadataprobe6925", + apiType: "embeddings", + baseUrl: "http://169.254.169.254/v1", + }); + + const res = await createEmbeddingResponse({ + model: "metadataprobe6925/whatever", + input: "hello world", + }); + + // Must not be silently treated as a trusted no-auth local provider — either + // rejected outright or still routed through the fallback (never authType "none"). + assert.notEqual(res.status, 200, "cloud-metadata host must not resolve as a no-auth provider"); +});