mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
merge: bring feat/6636-codex-session-import to release/v3.8.49 tip
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
OutboundUrlGuardError,
|
||||
getProviderValidationGuard,
|
||||
parseAndValidateNonMetadataUrl,
|
||||
parseAndValidatePublicUrl,
|
||||
parseOutboundUrl,
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
|
||||
function guardProviderNodeBaseUrl(baseUrl: string): void {
|
||||
const guard = getProviderValidationGuard();
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getSafeOutboundFetchErrorStatus,
|
||||
safeOutboundFetch,
|
||||
} from "@/shared/network/safeOutboundFetch";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags";
|
||||
import { providerNodeValidateSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import {
|
||||
getAntigravityModelsDiscoveryUrls,
|
||||
getAntigravityFetchAvailableModelsUrls,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import {
|
||||
getProviderOutboundGuard,
|
||||
getProviderValidationGuard,
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
} from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";
|
||||
import { deriveConfigFromRegistryModelsUrl } from "./discoveryConfig";
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
discoverBedrockNativeModels,
|
||||
isBedrockNativeApiError,
|
||||
} from "@omniroute/open-sse/services/bedrock.ts";
|
||||
import {
|
||||
discoverNotionWebModels,
|
||||
NOTION_WEB_FALLBACK_MODELS,
|
||||
} from "@omniroute/open-sse/services/notionWebModels.ts";
|
||||
import {
|
||||
AZURE_AI_DEFAULT_BASE_URL,
|
||||
buildAzureAiModelsUrl,
|
||||
@@ -527,6 +531,64 @@ export async function GET(
|
||||
if (localCatalog) return localCatalog;
|
||||
}
|
||||
|
||||
// #7600 follow-up: notion-web live catalog via cookie-auth getAvailableModels.
|
||||
// Needs spaceId (from cookie or getSpaces); falls back to seeded local catalog.
|
||||
if (provider === "notion-web") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const token = apiKey || accessToken;
|
||||
if (!token) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "No token configured — using cached catalog",
|
||||
localWarning: "No token configured — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return buildResponse({
|
||||
provider,
|
||||
connectionId,
|
||||
models: NOTION_WEB_FALLBACK_MODELS,
|
||||
source: "local_catalog",
|
||||
intentional: true,
|
||||
warning: "No token_v2 cookie — using seed Notion AI model list",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const discovery = await discoverNotionWebModels({
|
||||
token,
|
||||
fetchImpl: (url, init) =>
|
||||
safeOutboundFetch(url, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
...init,
|
||||
}),
|
||||
});
|
||||
return buildApiDiscoveryResponse(discovery.models);
|
||||
} catch (error) {
|
||||
console.log("Error fetching models from notion-web", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "Notion getAvailableModels failed — using cached catalog",
|
||||
localWarning: "Notion getAvailableModels failed — using seed catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return buildResponse({
|
||||
provider,
|
||||
connectionId,
|
||||
models: NOTION_WEB_FALLBACK_MODELS,
|
||||
source: "local_catalog",
|
||||
intentional: true,
|
||||
warning: "API unavailable — using seed Notion AI model list",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "bedrock") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
57
src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts
Normal file
57
src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* #7638: Mistral's quota-exhausted response is `401 {"detail":"Unauthorized"}` — byte-identical
|
||||
* to a genuinely revoked key. Unlike other providers, a bare Mistral 401 with no auth-specific
|
||||
* signal in the body cannot be trusted as a hard auth failure; the generic 401/403 branch in
|
||||
* classifyFailure() delegates here so it can return an ambiguous diagnosis instead of asserting
|
||||
* "Invalid API key" outright.
|
||||
* A message that DOES carry an explicit auth signal (e.g. "Invalid API key") still falls
|
||||
* through to the normal `upstream_auth_error` result — only the contentless case is ambiguous.
|
||||
*/
|
||||
export interface AuthOr401Diagnosis {
|
||||
type: string;
|
||||
source: string;
|
||||
message: string | null;
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
/** Param type for classifyFailure() in route.ts — extracted here to keep that frozen file's LOC flat. */
|
||||
export interface ClassifyFailureArgs {
|
||||
error: string;
|
||||
statusCode?: number | null;
|
||||
refreshFailed?: boolean;
|
||||
unsupported?: boolean;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
function isMistralAmbiguous401(provider: string | undefined, normalized: string): boolean {
|
||||
if (provider !== "mistral") return false;
|
||||
const hasAuthSignal =
|
||||
normalized.includes("invalid api key") ||
|
||||
normalized.includes("token invalid") ||
|
||||
normalized.includes("revoked") ||
|
||||
normalized.includes("access denied");
|
||||
return !hasAuthSignal;
|
||||
}
|
||||
|
||||
/** Decides the diagnosis for a 401/403 status: ambiguous (Mistral-only) or the generic auth error. */
|
||||
export function classifyAmbiguousOrAuthError(
|
||||
provider: string | undefined,
|
||||
normalized: string,
|
||||
message: string,
|
||||
numericStatus: number
|
||||
): AuthOr401Diagnosis {
|
||||
if (numericStatus === 401 && isMistralAmbiguous401(provider, normalized)) {
|
||||
return {
|
||||
type: "upstream_ambiguous_auth_or_quota",
|
||||
source: "upstream",
|
||||
message: message || null,
|
||||
code: String(numericStatus),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "upstream_auth_error",
|
||||
source: "upstream",
|
||||
message: message || null,
|
||||
code: String(numericStatus),
|
||||
};
|
||||
}
|
||||
129
src/app/api/providers/[id]/test/oauthTestConfig.ts
Normal file
129
src/app/api/providers/[id]/test/oauthTestConfig.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab";
|
||||
|
||||
// OAuth provider test endpoints. Extracted from route.ts (#7610) so adding a
|
||||
// provider entry doesn't grow the frozen route.ts file past its check-file-size
|
||||
// cap — this module carries no logic of its own beyond the GitLab URL builder.
|
||||
export const OAUTH_TEST_CONFIG = {
|
||||
claude: {
|
||||
// Claude doesn't have userinfo, we verify token exists and not expired
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
codex: {
|
||||
// Port of decolua/9router#347: probe the real Codex /responses endpoint instead
|
||||
// of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens
|
||||
// (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403.
|
||||
// Hitting the actual endpoint with a minimal invalid body returns 400 when
|
||||
// auth is accepted (the body is the reason for the failure) and 401/403 when
|
||||
// the token is bad. That is a real auth signal — checkExpiry alone could not
|
||||
// distinguish a revoked-but-not-yet-expired token from a working one.
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
method: "POST",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
originator: "codex-cli",
|
||||
"User-Agent": "codex-cli/1.0.18 (macOS; arm64)",
|
||||
},
|
||||
// Minimal invalid body — triggers a fast 400 without consuming quota.
|
||||
// #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a
|
||||
// codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason
|
||||
// (unsupported model, not "auth ok, body invalid") — collapsing the auth signal
|
||||
// so a bad token looks the same as a good one. "gpt-5.5" is served for
|
||||
// ChatGPT sessions; `input: []` still yields the intended 400.
|
||||
body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }),
|
||||
// 400 = bad request, but auth was accepted; only 401/403 means the token is bad.
|
||||
acceptStatuses: [400],
|
||||
refreshable: true,
|
||||
},
|
||||
antigravity: {
|
||||
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
refreshable: true,
|
||||
},
|
||||
xai: {
|
||||
url: "https://api.x.ai/v1/chat/completions",
|
||||
method: "POST",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "grok-4.3",
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
reasoning: { effort: "high" },
|
||||
}),
|
||||
refreshable: true,
|
||||
},
|
||||
github: {
|
||||
url: "https://api.github.com/user",
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
|
||||
},
|
||||
"gitlab-duo": {
|
||||
getUrl: (connection: any) =>
|
||||
buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData))
|
||||
.directAccessUrl,
|
||||
method: "POST",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
refreshable: true,
|
||||
},
|
||||
qwen: {
|
||||
// DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues.
|
||||
// Use checkExpiry instead — actual connectivity is validated via real requests.
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
cursor: {
|
||||
checkExpiry: true,
|
||||
},
|
||||
"kimi-coding": {
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
kilocode: {
|
||||
// Kilo OAuth does not expose a stable user-info endpoint in all environments.
|
||||
// Validate using token presence/expiry as a lightweight auth check.
|
||||
checkExpiry: true,
|
||||
},
|
||||
cline: {
|
||||
// Cline's /api/v1/models endpoint frequently returns stale auth errors even
|
||||
// with fresh tokens. Use checkExpiry instead — actual connectivity is validated
|
||||
// via real requests.
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
kiro: {
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
"amazon-q": {
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
"codebuddy-cn": {
|
||||
// Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port —
|
||||
// validate auth via token presence + refresh path. Live connectivity is
|
||||
// verified through real /v2/chat/completions traffic.
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
"grok-cli": {
|
||||
// #7610: was entirely absent from OAUTH_TEST_CONFIG, so "Test Connection"
|
||||
// always fell through to the generic "Provider test not supported" branch
|
||||
// below. Grok Build's cli-chat-proxy endpoint doesn't expose a lightweight
|
||||
// userinfo probe, and it enforces cli-specific headers (see
|
||||
// GrokCliExecutor.buildHeaders) that this shared prober doesn't send — so
|
||||
// mirror qwen/cline/kilocode's checkExpiry pattern instead of a live probe.
|
||||
// Real connectivity is still validated on every chat/completions request.
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
};
|
||||
@@ -17,133 +17,16 @@ import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { logProxyEvent } from "@/lib/proxyLogger";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import {
|
||||
buildGitLabOAuthEndpoints,
|
||||
isGitLabDirectAccessDisabled,
|
||||
resolveGitLabOAuthBaseUrl,
|
||||
} from "@/lib/oauth/gitlab";
|
||||
import { isGitLabDirectAccessDisabled } from "@/lib/oauth/gitlab";
|
||||
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
||||
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
||||
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
|
||||
import { OAUTH_TEST_CONFIG } from "./oauthTestConfig";
|
||||
|
||||
// Bound the OAuth probe so a hung upstream can't block the connection-test queue
|
||||
// forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey.
|
||||
const OAUTH_TEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
// OAuth provider test endpoints
|
||||
const OAUTH_TEST_CONFIG = {
|
||||
claude: {
|
||||
// Claude doesn't have userinfo, we verify token exists and not expired
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
codex: {
|
||||
// Port of decolua/9router#347: probe the real Codex /responses endpoint instead
|
||||
// of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens
|
||||
// (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403.
|
||||
// Hitting the actual endpoint with a minimal invalid body returns 400 when
|
||||
// auth is accepted (the body is the reason for the failure) and 401/403 when
|
||||
// the token is bad. That is a real auth signal — checkExpiry alone could not
|
||||
// distinguish a revoked-but-not-yet-expired token from a working one.
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
method: "POST",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
originator: "codex-cli",
|
||||
"User-Agent": "codex-cli/1.0.18 (macOS; arm64)",
|
||||
},
|
||||
// Minimal invalid body — triggers a fast 400 without consuming quota.
|
||||
// #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a
|
||||
// codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason
|
||||
// (unsupported model, not "auth ok, body invalid") — collapsing the auth signal
|
||||
// so a bad token looks the same as a good one. "gpt-5.5" is served for
|
||||
// ChatGPT sessions; `input: []` still yields the intended 400.
|
||||
body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }),
|
||||
// 400 = bad request, but auth was accepted; only 401/403 means the token is bad.
|
||||
acceptStatuses: [400],
|
||||
refreshable: true,
|
||||
},
|
||||
antigravity: {
|
||||
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
refreshable: true,
|
||||
},
|
||||
xai: {
|
||||
url: "https://api.x.ai/v1/chat/completions",
|
||||
method: "POST",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "grok-4.3",
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
reasoning: { effort: "high" },
|
||||
}),
|
||||
refreshable: true,
|
||||
},
|
||||
github: {
|
||||
url: "https://api.github.com/user",
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
|
||||
},
|
||||
"gitlab-duo": {
|
||||
getUrl: (connection: any) =>
|
||||
buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData))
|
||||
.directAccessUrl,
|
||||
method: "POST",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
refreshable: true,
|
||||
},
|
||||
qwen: {
|
||||
// DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues.
|
||||
// Use checkExpiry instead — actual connectivity is validated via real requests.
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
cursor: {
|
||||
checkExpiry: true,
|
||||
},
|
||||
"kimi-coding": {
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
kilocode: {
|
||||
// Kilo OAuth does not expose a stable user-info endpoint in all environments.
|
||||
// Validate using token presence/expiry as a lightweight auth check.
|
||||
checkExpiry: true,
|
||||
},
|
||||
cline: {
|
||||
// Cline's /api/v1/models endpoint frequently returns stale auth errors even
|
||||
// with fresh tokens. Use checkExpiry instead — actual connectivity is validated
|
||||
// via real requests.
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
kiro: {
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
"amazon-q": {
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
"codebuddy-cn": {
|
||||
// Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port —
|
||||
// validate auth via token presence + refresh path. Live connectivity is
|
||||
// verified through real /v2/chat/completions traffic.
|
||||
checkExpiry: true,
|
||||
refreshable: true,
|
||||
},
|
||||
};
|
||||
|
||||
import { CLI_RUNTIME_PROVIDER_MAP } from "./cliRuntimeProviderMap";
|
||||
|
||||
/** POST body is optional; when present, only known fields are validated. */
|
||||
@@ -188,12 +71,8 @@ export function classifyFailure({
|
||||
statusCode = null,
|
||||
refreshFailed = false,
|
||||
unsupported = false,
|
||||
}: {
|
||||
error: string;
|
||||
statusCode?: number | null;
|
||||
refreshFailed?: boolean;
|
||||
unsupported?: boolean;
|
||||
}) {
|
||||
provider,
|
||||
}: ClassifyFailureArgs) {
|
||||
const message = toSafeMessage(error, "Connection test failed");
|
||||
const normalized = message.toLowerCase();
|
||||
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
||||
@@ -214,7 +93,7 @@ export function classifyFailure({
|
||||
}
|
||||
|
||||
if (numericStatus === 401 || numericStatus === 403) {
|
||||
return makeDiagnosis("upstream_auth_error", "upstream", message, String(numericStatus));
|
||||
return classifyAmbiguousOrAuthError(provider, normalized, message, numericStatus);
|
||||
}
|
||||
|
||||
if (numericStatus === 429) {
|
||||
@@ -722,14 +601,14 @@ async function testApiKeyConnection(connection: any) {
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
diagnosis: classifyFailure({ error, unsupported: true }),
|
||||
diagnosis: classifyFailure({ error, unsupported: true, provider: connection.provider }),
|
||||
};
|
||||
}
|
||||
|
||||
const error = result.valid ? null : result.error || "Invalid API key";
|
||||
const diagnosis = result.valid
|
||||
? makeDiagnosis("ok", "upstream", null, null)
|
||||
: classifyFailure({ error });
|
||||
: classifyFailure({ error, statusCode: result.statusCode, provider: connection.provider });
|
||||
|
||||
return {
|
||||
valid: !!result.valid,
|
||||
@@ -813,7 +692,7 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
result.diagnosis ||
|
||||
(result.valid
|
||||
? makeDiagnosis("ok", "local", null, null)
|
||||
: classifyFailure({ error: result.error, statusCode: result.statusCode }));
|
||||
: classifyFailure({ error: result.error, statusCode: result.statusCode, provider }));
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
testStatus: result.valid ? "active" : "error",
|
||||
|
||||
@@ -11,7 +11,8 @@ import { z } from "zod";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isPrivateHost, arePrivateProviderUrlsAllowed } from "@/shared/network/outboundUrlGuard";
|
||||
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
|
||||
import { arePrivateProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import {
|
||||
testProxiesAgainstTarget,
|
||||
getProxyCandidates,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { encryptMetadata } from "@/lib/webhookDispatcher";
|
||||
import { isEncryptionEnabled } from "@/lib/db/encryption";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
|
||||
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;
|
||||
const WEBHOOK_EVENT_VALUES = [
|
||||
|
||||
@@ -13,11 +13,8 @@ import { buildDiscordPayload } from "@/lib/webhooks/integrations/discord";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { insertDelivery } from "@/lib/db/webhookDeliveries";
|
||||
import { recordWebhookDelivery } from "@/lib/localDb";
|
||||
import {
|
||||
parseAndValidateWebhookUrl,
|
||||
isPrivateHost,
|
||||
OutboundUrlGuardError,
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import { isPrivateHost, OutboundUrlGuardError } from "@/shared/network/outboundUrlGuard";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import crypto from "crypto";
|
||||
|
||||
const MAX_RESPONSE_BODY = 2048;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { encryptMetadata } from "@/lib/webhookDispatcher";
|
||||
import { isEncryptionEnabled } from "@/lib/db/encryption";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
|
||||
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;
|
||||
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import {
|
||||
parseAndValidateWebhookUrl,
|
||||
OutboundUrlGuardError,
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import { OutboundUrlGuardError } from "@/shared/network/outboundUrlGuard";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
|
||||
const validateUrlSchema = z.object({
|
||||
|
||||
@@ -379,6 +379,22 @@ export async function registerNodejs(): Promise<void> {
|
||||
console.warn("[STARTUP] Could not restore runtime settings:", msg);
|
||||
}
|
||||
|
||||
// Proactively start the credential-health sweep at boot so stale web-session
|
||||
// connections (cookies that expired overnight) get re-probed and recovered on
|
||||
// startup — instead of staying red until the first real request lazily imports
|
||||
// the on-demand credentialGate. Idempotent; self-disables via
|
||||
// OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK and its cadence is tunable via
|
||||
// CREDENTIAL_HEALTH_CHECK_INTERVAL. NOTE: this MUST live here (the real Next.js
|
||||
// instrumentation startup), NOT in the unused src/server-init.ts.
|
||||
try {
|
||||
const { initCredentialHealthCheck } = await import("@/lib/credentialHealth/scheduler");
|
||||
initCredentialHealthCheck();
|
||||
console.log("[STARTUP] Credential health scheduler started");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not start credential health scheduler:", msg);
|
||||
}
|
||||
|
||||
try {
|
||||
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
|
||||
initAuditLog();
|
||||
|
||||
@@ -107,11 +107,36 @@ export function humanizeCursorModelId(id: string): string {
|
||||
}
|
||||
|
||||
export function parseCursorAgentModels(text: string): string[] {
|
||||
const match = text.match(/Available models:\s*([^\n]+)/);
|
||||
if (!match) return [];
|
||||
// Older Cursor Agent releases only exposed the catalog as part of the
|
||||
// invalid-model error produced by `--model --help`.
|
||||
const legacyMatch = text.match(/Available models:\s*([^\n]+)/);
|
||||
if (legacyMatch) {
|
||||
return deduplicateCursorModelIds(legacyMatch[1].split(","));
|
||||
}
|
||||
|
||||
// Current releases expose an official `models` command whose output is:
|
||||
//
|
||||
// Available models
|
||||
//
|
||||
// auto - Auto (default)
|
||||
// gpt-5.3-codex - Codex 5.3
|
||||
const headerMatch = /(?:^|\n)Available models\s*(?:\n|$)/.exec(text);
|
||||
if (!headerMatch) return [];
|
||||
const lines = text.slice(headerMatch.index + headerMatch[0].length).split("\n");
|
||||
const ids: string[] = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("Tip:")) break;
|
||||
const separator = trimmed.indexOf(" - ");
|
||||
if (separator > 0) ids.push(trimmed.slice(0, separator));
|
||||
}
|
||||
return deduplicateCursorModelIds(ids);
|
||||
}
|
||||
|
||||
function deduplicateCursorModelIds(ids: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of match[1].split(",")) {
|
||||
for (const raw of ids) {
|
||||
const id = raw.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
@@ -138,12 +163,13 @@ export async function fetchCursorAgentModels(
|
||||
);
|
||||
}
|
||||
|
||||
// cursor-agent prints "Available models: ..." to stderr and exits non-zero
|
||||
// when given an unknown model id, so we intentionally pass `--help` as the
|
||||
// model value to coerce it into listing.
|
||||
const startedAt = Date.now();
|
||||
let result: { stdout: string; stderr: string };
|
||||
try {
|
||||
result = await runCursorAgent(binary, ["--model", "--help"], timeoutMs);
|
||||
// Modern Cursor Agent releases provide a dedicated catalog flag. Prefer the
|
||||
// flag over the equivalent `models` subcommand because older releases can
|
||||
// interpret an unknown positional subcommand as an agent prompt.
|
||||
result = await runCursorAgent(binary, ["--list-models"], timeoutMs);
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e?.code === "ENOENT") {
|
||||
@@ -151,11 +177,24 @@ export async function fetchCursorAgentModels(
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const combined = `${result.stdout}\n${result.stderr}`;
|
||||
let combined = `${result.stdout}\n${result.stderr}`;
|
||||
let ids = parseCursorAgentModels(combined);
|
||||
|
||||
// Backward compatibility for releases from before the dedicated catalog interface.
|
||||
if (ids.length === 0 && !/Authentication required|Not logged in/i.test(combined)) {
|
||||
const remainingTimeoutMs = timeoutMs - (Date.now() - startedAt);
|
||||
if (remainingTimeoutMs > 0) {
|
||||
result = await runCursorAgent(binary, ["--model", "--help"], remainingTimeoutMs);
|
||||
combined = `${result.stdout}\n${result.stderr}`;
|
||||
ids = parseCursorAgentModels(combined);
|
||||
}
|
||||
}
|
||||
|
||||
const ids = parseCursorAgentModels(combined);
|
||||
if (ids.length === 0) {
|
||||
throw new Error("cursor-agent did not return an 'Available models:' line");
|
||||
if (/Authentication required|Not logged in/i.test(combined)) {
|
||||
throw new Error("cursor-agent is not authenticated; run 'agent login' on the OmniRoute host");
|
||||
}
|
||||
throw new Error("cursor-agent did not return a model catalog from 'agent --list-models'");
|
||||
}
|
||||
|
||||
return ids.map((id) => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry";
|
||||
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import {
|
||||
SAFE_OUTBOUND_FETCH_PRESETS,
|
||||
SafeOutboundFetchError,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
WEB_COOKIE_PROVIDERS,
|
||||
} from "@/shared/constants/providers";
|
||||
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel";
|
||||
import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal";
|
||||
import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts";
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
resolveBaseUrl,
|
||||
} from "./validation/urlHelpers";
|
||||
import { STANDARD_USER_AGENT, directHttpsRequest, buildBearerHeaders } from "./validation/headers";
|
||||
import { validationRead, validationWrite, toValidationErrorResult } from "./validation/transport";
|
||||
import {
|
||||
validationRead,
|
||||
validationWrite,
|
||||
toValidationErrorResult,
|
||||
toWebCookieValidationErrorResult,
|
||||
} from "./validation/transport";
|
||||
import {
|
||||
validateDeepSeekWebProvider,
|
||||
validateQwenWebProvider,
|
||||
@@ -137,7 +142,7 @@ export async function validateWebCookieProvider({
|
||||
|
||||
if (!entry) {
|
||||
// Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g.
|
||||
// lmarena, gemini-business, poe-web, venice-web, v0-vercel-web) only expose a
|
||||
// gemini-business, poe-web, venice-web, v0-vercel-web) only expose a
|
||||
// marketing website URL, not a real API host. Probing `${website}/models`
|
||||
// does not reliably signal session validity for these —
|
||||
// live verification showed most return redirects or SPA 200s regardless of
|
||||
@@ -183,7 +188,7 @@ export async function validateWebCookieProvider({
|
||||
// for web-cookie auth, so a non-auth status is treated as a valid session.
|
||||
return { valid: true, error: null, unsupported: false };
|
||||
} catch (error: unknown) {
|
||||
return toValidationErrorResult(error);
|
||||
return toWebCookieValidationErrorResult(provider, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// from validation.ts (god-file decomposition). Pure header construction except directHttpsRequest,
|
||||
// which delegates to safeOutboundFetch with bypassProxyPatch. Behavior is byte-identical.
|
||||
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
|
||||
// Standardized desktop Chrome UA for web-cookie/no-auth session probes (minimizes anti-bot detection).
|
||||
export const STANDARD_USER_AGENT =
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// …). Extracted from validation.ts (god-file decomposition) — top-level functions/data with no
|
||||
// dispatcher-state captures; behavior is byte-identical to the original inline defs.
|
||||
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { withCustomUserAgent } from "./headers";
|
||||
import { toValidationErrorResult, validationWrite } from "./transport";
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
getSafeOutboundFetchErrorStatus,
|
||||
safeOutboundFetch,
|
||||
} from "@/shared/network/safeOutboundFetch";
|
||||
import { getProviderValidationGuard, isPrivateHost } from "@/shared/network/outboundUrlGuard";
|
||||
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts";
|
||||
|
||||
/**
|
||||
@@ -92,6 +93,35 @@ export function isSecurityBlockError(error: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// #7542 — web-cookie providers whose registry `baseUrl` is a POST-only streaming/completion
|
||||
// endpoint (no real `/models` listing API), so the generic `/models` probe in
|
||||
// validateWebCookieProvider() gets a redirect instead of a definitive 200/401/403. A blocked
|
||||
// redirect there is not a session-expiry signal — the endpoint just isn't shaped for the probe
|
||||
// — so it should degrade to "unsupported" the same way the discovery path already does for
|
||||
// REDIRECT_BLOCKED (#6267's buildDiscoveryErrorFallbackResponse).
|
||||
//
|
||||
// Scoped to `lmarena` only (root-caused and regression-tested for #7542): the other web-cookie
|
||||
// providers sharing a POST-only baseUrl shape (doubao-web, huggingchat, yuanbao-web,
|
||||
// zenmux-free, zai-web) have not been individually verified to actually redirect on this probe
|
||||
// rather than 404/405 — do not add them here without a proven repro per provider (see
|
||||
// #7542 plan-file, "Risks").
|
||||
const WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE = new Set(["lmarena"]);
|
||||
|
||||
export function toWebCookieValidationErrorResult(provider: string, error: unknown) {
|
||||
if (
|
||||
error instanceof SafeOutboundFetchError &&
|
||||
error.code === "REDIRECT_BLOCKED" &&
|
||||
WEB_COOKIE_PROVIDERS_WITH_UNRELIABLE_MODELS_PROBE.has(provider)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Provider validation not supported",
|
||||
unsupported: true as const,
|
||||
};
|
||||
}
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
export function toValidationErrorResult(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "Validation failed");
|
||||
const statusCode = getSafeOutboundFetchErrorStatus(error);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import crypto from "crypto";
|
||||
import { encrypt, decrypt } from "./db/encryption";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuard";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import type { WebhookEvent } from "./webhooks/eventDescriptions";
|
||||
|
||||
export type { WebhookEvent };
|
||||
|
||||
@@ -32,6 +32,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/cli-tools/omp-settings", // spawns `which omp` to detect the CLI install (Hard Rules #15 + #17, #6318)
|
||||
"/api/cli-tools/letta-settings", // spawns `which letta` to detect the CLI install (Hard Rules #15 + #17, #6318)
|
||||
"/api/cli-tools/grok-build-settings", // GET calls getCliRuntimeStatus("grok-build"), which spawns a child process to locate + healthcheck the `grok` binary — same transitive-spawn surface that classified /api/skills/collect/ (Hard Rules #15 + #17). Writing ~/.grok/config.toml is inherently a local-machine operation, so loopback-only costs no real capability.
|
||||
"/api/cli-tools/forge-settings", // spawns via getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263)
|
||||
"/api/cli-tools/jcode-settings", // spawns via getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263)
|
||||
"/api/services/", // T-10: embedded service lifecycle (spawn child processes)
|
||||
"/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs
|
||||
"/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass
|
||||
|
||||
@@ -372,7 +372,8 @@ export const WEB_COOKIE_PROVIDERS = {
|
||||
riskNoticeVariant: "webCookie",
|
||||
authHint:
|
||||
"Paste your token_v2 cookie value from notion.so (DevTools → Application → Cookies). " +
|
||||
"Optionally append `; space_id=...` and/or `; notion_browser_id=...` if your workspace requires them.",
|
||||
"Include `; space_id=<workspace-uuid>` so live model discovery (getAvailableModels) can list GPT/Claude/Gemini/etc. " +
|
||||
"Optionally also `; notion_browser_id=...` / `; notion_user_id=...`.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { isIP } from "node:net";
|
||||
import { resolveFeatureFlag } from "@/shared/utils/featureFlags";
|
||||
|
||||
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
export const PROVIDER_URL_BLOCKED_MESSAGE = "Blocked private or local provider URL";
|
||||
export const CLOUD_METADATA_BLOCKED_MESSAGE = "Blocked cloud-metadata endpoint";
|
||||
export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS";
|
||||
// #5066: scoped to provider validation/use. Allows local/private provider endpoints
|
||||
// (127.0.0.1, localhost, LAN) so local-first OpenAI-compatible providers validate, while
|
||||
// cloud-metadata endpoints stay blocked. Defaults ON (OmniRoute is local-first); operators
|
||||
// who only use public providers can disable it to restore strict SSRF blocking.
|
||||
export const LOCAL_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS";
|
||||
|
||||
// "block-metadata": allow private/LAN hosts but still reject cloud-metadata / link-local
|
||||
// endpoints (the SSRF→IAM-credential pivot). Used by the provider-validation path under the
|
||||
@@ -175,102 +166,11 @@ export function parseAndValidateNonMetadataUrl(input: string | URL) {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook variant of {@link parseAndValidatePublicUrl}. Webhooks legitimately point at
|
||||
* internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments,
|
||||
* so the private-host block is gated behind the same explicit opt-in used for private
|
||||
* provider URLs (`OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`, default OFF). Protocol and
|
||||
* embedded-credential checks in {@link parseOutboundUrl} remain unconditional. (#3269)
|
||||
*/
|
||||
export function parseAndValidateWebhookUrl(input: string | URL) {
|
||||
const url = parseOutboundUrl(input);
|
||||
|
||||
// Cloud-metadata / link-local endpoints are NEVER a valid webhook target — block them
|
||||
// even when the private opt-in is enabled (SSRF→IAM-credential pivot). (#3269)
|
||||
if (isCloudMetadataHost(url.hostname)) {
|
||||
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
|
||||
code: "OUTBOUND_URL_GUARD_BLOCKED",
|
||||
url: url.toString(),
|
||||
hostname: url.hostname || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) {
|
||||
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
|
||||
code: "OUTBOUND_URL_GUARD_BLOCKED",
|
||||
url: url.toString(),
|
||||
hostname: url.hostname || null,
|
||||
});
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function isTrueValue(raw: unknown): boolean {
|
||||
if (typeof raw !== "string") return false;
|
||||
return TRUE_ENV_VALUES.has(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function arePrivateProviderUrlsAllowed() {
|
||||
// 1) DB override takes precedence — it represents an explicit user toggle in
|
||||
// the dashboard ("Allow Private Provider URLs"). This is critical for the
|
||||
// Electron build (#2575) where the server is spawned with the env value
|
||||
// captured at boot, so subsequent UI toggles only land in the DB and the
|
||||
// env-first ordering would otherwise mask them.
|
||||
try {
|
||||
const dbValue = resolveFeatureFlag(PRIVATE_PROVIDER_URLS_ENV);
|
||||
if (isTrueValue(dbValue)) return true;
|
||||
} catch {
|
||||
// DB not initialized yet — fall through to env-only check.
|
||||
}
|
||||
|
||||
// 2) Explicit env opt-in (for headless/Docker users who set it before boot).
|
||||
if (isTrueValue(process.env[PRIVATE_PROVIDER_URLS_ENV])) return true;
|
||||
|
||||
// 3) Legacy escape hatch — disabling the outbound guard implies allowing
|
||||
// private URLs.
|
||||
const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"];
|
||||
if (
|
||||
typeof legacyValue === "string" &&
|
||||
["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
|
||||
return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
|
||||
}
|
||||
|
||||
/**
|
||||
* #5066: whether provider endpoints on local/private addresses are permitted. Defaults ON
|
||||
* (OmniRoute is local-first — local OpenAI-compatible providers should validate out of the
|
||||
* box). Disable via the `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` flag (DB toggle or env) to
|
||||
* restore strict public-only SSRF blocking. Cloud-metadata stays blocked regardless.
|
||||
*/
|
||||
export function areLocalProviderUrlsAllowed(): boolean {
|
||||
try {
|
||||
const dbValue = resolveFeatureFlag(LOCAL_PROVIDER_URLS_ENV);
|
||||
if (dbValue !== undefined && dbValue !== "") return isTrueValue(dbValue);
|
||||
} catch {
|
||||
// DB not initialized yet — fall through to env / default.
|
||||
}
|
||||
const envValue = process.env[LOCAL_PROVIDER_URLS_ENV];
|
||||
if (typeof envValue === "string" && envValue !== "") return isTrueValue(envValue);
|
||||
// Default ON.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard mode for the provider VALIDATION/use path (not webhooks or remote images). Precedence:
|
||||
* 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users).
|
||||
* 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS).
|
||||
* 3. otherwise → "public-only" (strict).
|
||||
*/
|
||||
export function getProviderValidationGuard(): OutboundUrlGuardMode {
|
||||
if (arePrivateProviderUrlsAllowed()) return "none";
|
||||
if (areLocalProviderUrlsAllowed()) return "block-metadata";
|
||||
return "public-only";
|
||||
}
|
||||
// NOTE (#7682): `arePrivateProviderUrlsAllowed`, `areLocalProviderUrlsAllowed`,
|
||||
// `getProviderOutboundGuard`, `getProviderValidationGuard`, and `parseAndValidateWebhookUrl`
|
||||
// live in the sibling `./outboundUrlGuardPolicy.ts` module, NOT here. Those helpers need
|
||||
// `@/shared/utils/featureFlags` (which transitively pulls in the DB layer), and this file is
|
||||
// loaded by the packaged CLI (`omniroute setup-opencode` → cli-helper/config-generator/
|
||||
// opencode.ts) where no `tsconfig.json` is present to resolve the `@/*` path alias. Keeping
|
||||
// this module free of ANY `@/`-aliased import is what makes it safe to load from the CLI.
|
||||
// Do not add a `@/`-aliased import here — see docs/security/… (packaging) and #7682.
|
||||
|
||||
123
src/shared/network/outboundUrlGuardPolicy.ts
Normal file
123
src/shared/network/outboundUrlGuardPolicy.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { resolveFeatureFlag } from "@/shared/utils/featureFlags";
|
||||
import {
|
||||
OutboundUrlGuardError,
|
||||
PROVIDER_URL_BLOCKED_MESSAGE,
|
||||
isCloudMetadataHost,
|
||||
isPrivateHost,
|
||||
parseOutboundUrl,
|
||||
type OutboundUrlGuardMode,
|
||||
} from "./outboundUrlGuard";
|
||||
|
||||
// #7682: this module is the DB/feature-flag-backed half of the outbound URL guard, split out
|
||||
// of `./outboundUrlGuard.ts` so the CLI (`omniroute setup-opencode`, loaded via tsx with no
|
||||
// tsconfig.json in a global npm install) never has to resolve the `@/` alias. Only Next.js /
|
||||
// webpack-bundled server code (never the CLI) should import from here.
|
||||
|
||||
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS";
|
||||
// #5066: scoped to provider validation/use. Allows local/private provider endpoints
|
||||
// (127.0.0.1, localhost, LAN) so local-first OpenAI-compatible providers validate, while
|
||||
// cloud-metadata endpoints stay blocked. Defaults ON (OmniRoute is local-first); operators
|
||||
// who only use public providers can disable it to restore strict SSRF blocking.
|
||||
export const LOCAL_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS";
|
||||
|
||||
function isTrueValue(raw: unknown): boolean {
|
||||
if (typeof raw !== "string") return false;
|
||||
return TRUE_ENV_VALUES.has(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function arePrivateProviderUrlsAllowed() {
|
||||
// 1) DB override takes precedence — it represents an explicit user toggle in
|
||||
// the dashboard ("Allow Private Provider URLs"). This is critical for the
|
||||
// Electron build (#2575) where the server is spawned with the env value
|
||||
// captured at boot, so subsequent UI toggles only land in the DB and the
|
||||
// env-first ordering would otherwise mask them.
|
||||
try {
|
||||
const dbValue = resolveFeatureFlag(PRIVATE_PROVIDER_URLS_ENV);
|
||||
if (isTrueValue(dbValue)) return true;
|
||||
} catch {
|
||||
// DB not initialized yet — fall through to env-only check.
|
||||
}
|
||||
|
||||
// 2) Explicit env opt-in (for headless/Docker users who set it before boot).
|
||||
if (isTrueValue(process.env[PRIVATE_PROVIDER_URLS_ENV])) return true;
|
||||
|
||||
// 3) Legacy escape hatch — disabling the outbound guard implies allowing
|
||||
// private URLs.
|
||||
const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"];
|
||||
if (
|
||||
typeof legacyValue === "string" &&
|
||||
["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
|
||||
return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
|
||||
}
|
||||
|
||||
/**
|
||||
* #5066: whether provider endpoints on local/private addresses are permitted. Defaults ON
|
||||
* (OmniRoute is local-first — local OpenAI-compatible providers should validate out of the
|
||||
* box). Disable via the `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` flag (DB toggle or env) to
|
||||
* restore strict public-only SSRF blocking. Cloud-metadata stays blocked regardless.
|
||||
*/
|
||||
export function areLocalProviderUrlsAllowed(): boolean {
|
||||
try {
|
||||
const dbValue = resolveFeatureFlag(LOCAL_PROVIDER_URLS_ENV);
|
||||
if (dbValue !== undefined && dbValue !== "") return isTrueValue(dbValue);
|
||||
} catch {
|
||||
// DB not initialized yet — fall through to env / default.
|
||||
}
|
||||
const envValue = process.env[LOCAL_PROVIDER_URLS_ENV];
|
||||
if (typeof envValue === "string" && envValue !== "") return isTrueValue(envValue);
|
||||
// Default ON.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard mode for the provider VALIDATION/use path (not webhooks or remote images). Precedence:
|
||||
* 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users).
|
||||
* 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS).
|
||||
* 3. otherwise → "public-only" (strict).
|
||||
*/
|
||||
export function getProviderValidationGuard(): OutboundUrlGuardMode {
|
||||
if (arePrivateProviderUrlsAllowed()) return "none";
|
||||
if (areLocalProviderUrlsAllowed()) return "block-metadata";
|
||||
return "public-only";
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook variant of `parseAndValidatePublicUrl`. Webhooks legitimately point at
|
||||
* internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments,
|
||||
* so the private-host block is gated behind the same explicit opt-in used for private
|
||||
* provider URLs (`OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`, default OFF). Protocol and
|
||||
* embedded-credential checks in `parseOutboundUrl` remain unconditional. (#3269)
|
||||
*/
|
||||
export function parseAndValidateWebhookUrl(input: string | URL) {
|
||||
const url = parseOutboundUrl(input);
|
||||
|
||||
// Cloud-metadata / link-local endpoints are NEVER a valid webhook target — block them
|
||||
// even when the private opt-in is enabled (SSRF→IAM-credential pivot). (#3269)
|
||||
if (isCloudMetadataHost(url.hostname)) {
|
||||
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
|
||||
code: "OUTBOUND_URL_GUARD_BLOCKED",
|
||||
url: url.toString(),
|
||||
hostname: url.hostname || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) {
|
||||
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
|
||||
code: "OUTBOUND_URL_GUARD_BLOCKED",
|
||||
url: url.toString(),
|
||||
hostname: url.hostname || null,
|
||||
});
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import { isIP } from "node:net";
|
||||
import dns from "node:dns";
|
||||
import {
|
||||
type OutboundUrlGuardMode,
|
||||
getProviderOutboundGuard,
|
||||
isPrivateHost,
|
||||
parseAndValidatePublicUrl,
|
||||
parseOutboundUrl,
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
|
||||
const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
const DEFAULT_MAX_REDIRECTS = 3;
|
||||
|
||||
@@ -322,6 +322,11 @@ export const updateSettingsSchema = z.object({
|
||||
cliproxyapi_fallback_enabled: z.boolean().optional(),
|
||||
cliproxyapi_url: z.string().url().max(500).optional(),
|
||||
cliproxyapi_fallback_codes: z.string().max(200).optional(),
|
||||
// #7645: dedicated CLIProxyAPI credential. CLIProxyAPI requires its own
|
||||
// separately-configured `api-keys:` credential and rejects any other token
|
||||
// with 401 — without this field, the fallback/passthrough legs had no way
|
||||
// to authenticate except by reusing the (incompatible) native provider key.
|
||||
cliproxyapi_api_key: z.string().max(500).optional(),
|
||||
// CLIProxyAPI model mapping (Record<string, string>)
|
||||
cliproxyapi_model_mapping: z.record(z.string(), z.string()).optional(),
|
||||
// Model lockout settings
|
||||
|
||||
@@ -35,16 +35,41 @@ function getReservedProviderPrefixes(): Set<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combined model alias map that merges both alias stores:
|
||||
* Fold `settings.wildcardAliases` ({pattern,target}[]) — the store the Settings
|
||||
* UI's "Wildcard Pattern" mode writes to (ModelAliasesUnified.tsx::addWildcardAlias
|
||||
* -> PATCH /api/settings) — into `pattern -> target` map entries so the T13
|
||||
* wildcard step in getModelInfoCore() (which treats every key of the merged alias
|
||||
* map as a candidate glob pattern) can see them (#7693). Without this the
|
||||
* feature persists but is never consulted at request time.
|
||||
*/
|
||||
function buildWildcardAliasMap(settings: Record<string, unknown>): Record<string, unknown> {
|
||||
const wildcardEntries = Array.isArray(settings.wildcardAliases)
|
||||
? (settings.wildcardAliases as Array<{ pattern?: unknown; target?: unknown }>)
|
||||
: [];
|
||||
const wildcardMap: Record<string, unknown> = {};
|
||||
for (const entry of wildcardEntries) {
|
||||
if (entry && typeof entry.pattern === "string" && typeof entry.target === "string") {
|
||||
wildcardMap[entry.pattern] = entry.target;
|
||||
}
|
||||
}
|
||||
return wildcardMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combined model alias map that merges all alias stores:
|
||||
* 1. DB-namespace aliases (key_value WHERE namespace='modelAliases') — set via
|
||||
* /api/models/alias/ and seeded at startup.
|
||||
* 2. Settings-based aliases (settings.modelAliases) — set via the Settings UI and
|
||||
* 2. Settings-based exact aliases (settings.modelAliases) — set via the Settings UI and
|
||||
* /api/settings/model-aliases/ (stored as a JSON blob in namespace='settings').
|
||||
* 3. Settings-based wildcard aliases (settings.wildcardAliases) — set via the Settings
|
||||
* UI's "Wildcard Pattern" mode, PATCH /api/settings (#7693).
|
||||
*
|
||||
* Settings-based aliases take priority so that UI configuration always wins.
|
||||
* Without this merge, aliases configured via the Settings UI were never consulted
|
||||
* during provider routing, causing provider inference (e.g. /^gpt-/ → openai) to
|
||||
* silently override them (issue #2618 / #2208).
|
||||
* Settings-based exact aliases take priority over DB-namespace aliases so that UI
|
||||
* configuration always wins. Without this merge, aliases configured via the Settings
|
||||
* UI were never consulted during provider routing, causing provider inference (e.g.
|
||||
* /^gpt-/ → openai) to silently override them (issue #2618 / #2208). Wildcard entries
|
||||
* are folded in last: they are keyed by pattern string (containing `*`/`?`), which
|
||||
* cannot collide with a real model id, so ordering never affects exact-alias lookups.
|
||||
*/
|
||||
async function getCombinedModelAliases(): Promise<Record<string, unknown>> {
|
||||
const [dbAliases, settings] = await Promise.all([
|
||||
@@ -59,8 +84,9 @@ async function getCombinedModelAliases(): Promise<Record<string, unknown>> {
|
||||
? (settings.modelAliases as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
// Settings-based aliases win over DB-namespace aliases on key collision
|
||||
return { ...dbAliases, ...settingsAliases };
|
||||
const wildcardMap = buildWildcardAliasMap(settings);
|
||||
|
||||
return { ...dbAliases, ...settingsAliases, ...wildcardMap };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user