From 98ea72e4f1b9f0f956a383407f77f5667e705f2a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 19 Jul 2026 09:51:11 -0300 Subject: [PATCH] fix(cli): split outboundUrlGuard's DB helpers so setup-opencode packages cleanly (#7682) --- .../fixes/7682-opencode-shared-alias.md | 1 + src/app/api/provider-nodes/urlGuard.ts | 2 +- src/app/api/provider-nodes/validate/route.ts | 2 +- .../[id]/models/discovery/normalizers.ts | 2 +- src/app/api/providers/[id]/models/route.ts | 2 +- src/app/api/proxy-fallback/test/route.ts | 3 +- src/app/api/webhooks/[id]/route.ts | 2 +- src/app/api/webhooks/[id]/test/route.ts | 7 +- src/app/api/webhooks/route.ts | 2 +- src/app/api/webhooks/validate-url/route.ts | 6 +- src/lib/providers/imageValidation.ts | 2 +- src/lib/providers/validation.ts | 2 +- src/lib/providers/validation/headers.ts | 2 +- .../providers/validation/searchProviders.ts | 2 +- src/lib/providers/validation/transport.ts | 3 +- src/lib/webhookDispatcher.ts | 2 +- src/shared/network/outboundUrlGuard.ts | 116 ++--------------- src/shared/network/outboundUrlGuardPolicy.ts | 123 ++++++++++++++++++ src/shared/network/remoteImageFetch.ts | 2 +- ...i-setup-opencode-nested-alias-7682.test.ts | 42 ++++++ .../outbound-url-guard-feature-flag.test.ts | 8 +- .../provider-models-route-lan-guard.test.ts | 3 +- .../unit/webhook-metadata-guard-3269.test.ts | 5 +- tests/unit/webhook-private-optin-3269.test.ts | 5 +- 24 files changed, 207 insertions(+), 139 deletions(-) create mode 100644 changelog.d/fixes/7682-opencode-shared-alias.md create mode 100644 src/shared/network/outboundUrlGuardPolicy.ts create mode 100644 tests/unit/cli-setup-opencode-nested-alias-7682.test.ts diff --git a/changelog.d/fixes/7682-opencode-shared-alias.md b/changelog.d/fixes/7682-opencode-shared-alias.md new file mode 100644 index 00000000000..61d486efe57 --- /dev/null +++ b/changelog.d/fixes/7682-opencode-shared-alias.md @@ -0,0 +1 @@ +- fix(cli): split `outboundUrlGuard.ts`'s DB/feature-flag helpers into `outboundUrlGuardPolicy.ts` so `omniroute setup-opencode` no longer crashes with `Cannot find package '@/shared'` on a global npm install (#7682) diff --git a/src/app/api/provider-nodes/urlGuard.ts b/src/app/api/provider-nodes/urlGuard.ts index 683aab64953..e98b82f2ddd 100644 --- a/src/app/api/provider-nodes/urlGuard.ts +++ b/src/app/api/provider-nodes/urlGuard.ts @@ -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(); diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index ffda9aa564c..b864f27130b 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -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"; diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index b141de5b71b..a95c5d437ff 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -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, diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 8bff5e2b491..728269a8fab 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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"; diff --git a/src/app/api/proxy-fallback/test/route.ts b/src/app/api/proxy-fallback/test/route.ts index bfd8d8235e5..ed1bc8b66c8 100644 --- a/src/app/api/proxy-fallback/test/route.ts +++ b/src/app/api/proxy-fallback/test/route.ts @@ -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, diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index b9c59358585..5490da1ed41 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -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 = [ diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts index 6debd9a14ab..7e81f9e9c62 100644 --- a/src/app/api/webhooks/[id]/test/route.ts +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -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; diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index ab5990749e8..95047f3aa0f 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -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; diff --git a/src/app/api/webhooks/validate-url/route.ts b/src/app/api/webhooks/validate-url/route.ts index a58f1f7fd61..ae8ee29d1e3 100644 --- a/src/app/api/webhooks/validate-url/route.ts +++ b/src/app/api/webhooks/validate-url/route.ts @@ -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({ diff --git a/src/lib/providers/imageValidation.ts b/src/lib/providers/imageValidation.ts index de5057acdaf..444517a608f 100644 --- a/src/lib/providers/imageValidation.ts +++ b/src/lib/providers/imageValidation.ts @@ -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, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 2a34969086c..dbcd2041a22 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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"; diff --git a/src/lib/providers/validation/headers.ts b/src/lib/providers/validation/headers.ts index aabc65339ed..a5a3a13098a 100644 --- a/src/lib/providers/validation/headers.ts +++ b/src/lib/providers/validation/headers.ts @@ -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 = diff --git a/src/lib/providers/validation/searchProviders.ts b/src/lib/providers/validation/searchProviders.ts index b49f77ed63e..ecfa79b5946 100644 --- a/src/lib/providers/validation/searchProviders.ts +++ b/src/lib/providers/validation/searchProviders.ts @@ -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"; diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index 08a1d97600c..8fb49af5cef 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -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"; /** diff --git a/src/lib/webhookDispatcher.ts b/src/lib/webhookDispatcher.ts index 09165bcbbec..9902653c83a 100644 --- a/src/lib/webhookDispatcher.ts +++ b/src/lib/webhookDispatcher.ts @@ -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 }; diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts index 1f51145c9de..8a63de5be72 100644 --- a/src/shared/network/outboundUrlGuard.ts +++ b/src/shared/network/outboundUrlGuard.ts @@ -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. diff --git a/src/shared/network/outboundUrlGuardPolicy.ts b/src/shared/network/outboundUrlGuardPolicy.ts new file mode 100644 index 00000000000..cf32a0c612f --- /dev/null +++ b/src/shared/network/outboundUrlGuardPolicy.ts @@ -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; +} diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts index eaa778dff29..e05f32b581b 100644 --- a/src/shared/network/remoteImageFetch.ts +++ b/src/shared/network/remoteImageFetch.ts @@ -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; diff --git a/tests/unit/cli-setup-opencode-nested-alias-7682.test.ts b/tests/unit/cli-setup-opencode-nested-alias-7682.test.ts new file mode 100644 index 00000000000..97bfdc6c8f7 --- /dev/null +++ b/tests/unit/cli-setup-opencode-nested-alias-7682.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, cpSync, symlinkSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, "..", ".."); + +test("config-generator/opencode.ts imports cleanly with no tsconfig.json in scope (repro #7682)", () => { + const stage = mkdtempSync(join(tmpdir(), "omniroute-pkg-stage-7682-")); + try { + for (const rel of ["bin", "src/lib", "src/shared"]) { + cpSync(join(REPO_ROOT, rel), join(stage, rel), { recursive: true }); + } + cpSync(join(REPO_ROOT, "package.json"), join(stage, "package.json")); + symlinkSync(join(REPO_ROOT, "node_modules"), join(stage, "node_modules"), "dir"); + + const probeScript = join(stage, "probe-import.mjs"); + writeFileSync( + probeScript, + `await import("tsx/esm"); + await import("./src/lib/cli-helper/config-generator/opencode.ts"); + console.log("IMPORT_OK"); + ` + ); + + const result = spawnSync(process.execPath, [probeScript], { cwd: stage, encoding: "utf8" }); + + assert.equal( + result.stdout.includes("IMPORT_OK"), + true, + `expected config-generator/opencode.ts to import cleanly from a tsconfig-less ` + + `directory (as it will inside a real global npm install), but it failed:\n` + + `stdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } finally { + rmSync(stage, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/outbound-url-guard-feature-flag.test.ts b/tests/unit/outbound-url-guard-feature-flag.test.ts index bf069f1dc57..a58dacb68a8 100644 --- a/tests/unit/outbound-url-guard-feature-flag.test.ts +++ b/tests/unit/outbound-url-guard-feature-flag.test.ts @@ -39,7 +39,7 @@ test("arePrivateProviderUrlsAllowed honors DB override = 'true' even when env is await withEnv("false", async () => { await withDbOverride("true", async () => { const { arePrivateProviderUrlsAllowed } = - await import("../../src/shared/network/outboundUrlGuard.ts"); + await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); assert.equal( arePrivateProviderUrlsAllowed(), true, @@ -53,7 +53,7 @@ test("arePrivateProviderUrlsAllowed returns false when DB override = 'false' and await withEnv(undefined, async () => { await withDbOverride("false", async () => { const { arePrivateProviderUrlsAllowed } = - await import("../../src/shared/network/outboundUrlGuard.ts"); + await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); assert.equal(arePrivateProviderUrlsAllowed(), false); }); }); @@ -63,7 +63,7 @@ test("arePrivateProviderUrlsAllowed honors env = 'true' when DB has no override" await withEnv("true", async () => { await withDbOverride(undefined, async () => { const { arePrivateProviderUrlsAllowed } = - await import("../../src/shared/network/outboundUrlGuard.ts"); + await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); assert.equal(arePrivateProviderUrlsAllowed(), true); }); }); @@ -73,7 +73,7 @@ test("arePrivateProviderUrlsAllowed default (no env, no DB) returns false", asyn await withEnv(undefined, async () => { await withDbOverride(undefined, async () => { const { arePrivateProviderUrlsAllowed } = - await import("../../src/shared/network/outboundUrlGuard.ts"); + await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); assert.equal(arePrivateProviderUrlsAllowed(), false); }); }); diff --git a/tests/unit/provider-models-route-lan-guard.test.ts b/tests/unit/provider-models-route-lan-guard.test.ts index 276399904c9..cc490785a9f 100644 --- a/tests/unit/provider-models-route-lan-guard.test.ts +++ b/tests/unit/provider-models-route-lan-guard.test.ts @@ -19,6 +19,7 @@ const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); const outboundUrlGuard = await import("../../src/shared/network/outboundUrlGuard.ts"); +const outboundUrlGuardPolicy = await import("../../src/shared/network/outboundUrlGuardPolicy.ts"); const originalFetch = globalThis.fetch; const originalAllowPrivateProviderUrls = process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; @@ -77,7 +78,7 @@ test("#6939: getProviderOutboundGuard() and getProviderValidationGuard() agree f delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; - const validationGuard = outboundUrlGuard.getProviderValidationGuard(); + const validationGuard = outboundUrlGuardPolicy.getProviderValidationGuard(); assert.equal(validationGuard, "block-metadata"); // The models route must resolve a guard for LAN-local model discovery that is at least as diff --git a/tests/unit/webhook-metadata-guard-3269.test.ts b/tests/unit/webhook-metadata-guard-3269.test.ts index ef3dd6171ec..b08396d859b 100644 --- a/tests/unit/webhook-metadata-guard-3269.test.ts +++ b/tests/unit/webhook-metadata-guard-3269.test.ts @@ -14,9 +14,12 @@ import path from "node:path"; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-wh-meta-3269-")); -const { parseAndValidateWebhookUrl, isCloudMetadataHost, OutboundUrlGuardError } = await import( +const { isCloudMetadataHost, OutboundUrlGuardError } = await import( "../../src/shared/network/outboundUrlGuard.ts" ); +const { parseAndValidateWebhookUrl } = await import( + "../../src/shared/network/outboundUrlGuardPolicy.ts" +); const { resetDbInstance } = await import("../../src/lib/db/core.ts"); const FLAG = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS"; diff --git a/tests/unit/webhook-private-optin-3269.test.ts b/tests/unit/webhook-private-optin-3269.test.ts index 7edc8bb1e3f..196b9ef337e 100644 --- a/tests/unit/webhook-private-optin-3269.test.ts +++ b/tests/unit/webhook-private-optin-3269.test.ts @@ -14,8 +14,9 @@ import path from "node:path"; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-wh-3269-")); -const { parseAndValidateWebhookUrl, OutboundUrlGuardError } = await import( - "../../src/shared/network/outboundUrlGuard.ts" +const { OutboundUrlGuardError } = await import("../../src/shared/network/outboundUrlGuard.ts"); +const { parseAndValidateWebhookUrl } = await import( + "../../src/shared/network/outboundUrlGuardPolicy.ts" ); const { resetDbInstance } = await import("../../src/lib/db/core.ts");