mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Private/LAN embeddings provider_nodes (10.0.0.0/8, 192.168.0.0/16, 100.64.0.0/10 CGNAT) were excluded by a hand-rolled hostname filter that only matched localhost/127.0.0.1/172.16-31, forcing them through the apikey/bearer credential fallback and returning 401 for keyless local providers like a LAN Ollama instance. Reuse the shared isPrivateHost()/isCloudMetadataHost() classification from outboundUrlGuard.ts in both the dynamic-provider filter and the provider_node fallback branch, so any private host resolves to authType 'none' while cloud-metadata endpoints stay blocked.
This commit is contained in:
committed by
GitHub
parent
152d77cf4b
commit
c1fc661c90
1
changelog.d/fixes/6925-embeddings-lan-noauth.md
Normal file
1
changelog.d/fixes/6925-embeddings-lan-noauth.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): classify LAN embeddings providers (10/8, 192.168/16, CGNAT) as no-auth instead of forcing bearer auth (#6925)
|
||||
@@ -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<string, unknown> & { model: string };
|
||||
type ProviderCredentialsResult = Awaited<ReturnType<typeof getProviderCredentials>>;
|
||||
|
||||
// #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;
|
||||
|
||||
125
tests/unit/embeddings-lan-noauth-6925.test.ts
Normal file
125
tests/unit/embeddings-lan-noauth-6925.test.ts
Normal file
@@ -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<string, string> } | null = null;
|
||||
globalThis.fetch = async (url: RequestInfo | URL, options: RequestInit = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
headers: (options.headers as Record<string, string>) || {},
|
||||
};
|
||||
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<string, string> } | null = null;
|
||||
globalThis.fetch = async (_url: RequestInfo | URL, options: RequestInit = {}) => {
|
||||
captured = { headers: (options.headers as Record<string, string>) || {} };
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user