Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ od skill list --scenario marketing

**Security model.** Read-only by default, the daemon binds to `127.0.0.1`, and SSRF is blocked at the proxy edge. LAN exposure requires an explicit `OD_BIND_HOST` plus `OD_ALLOWED_ORIGINS`. Connector credentials and live-artifact preview routes stay loopback-only regardless.

**Internally-hosted model endpoints.** To prevent SSRF, the daemon blocks provider base URLs that resolve to private/internal address ranges (RFC1918, link-local, CGNAT, and cloud-metadata IPs) by default, surfacing `Internal IPs blocked`. If you run an internally-hosted gateway (e.g. LiteLLM or Ollama on a VPN-only `10.x`/`192.168.x` address), opt that host out with `OD_ALLOWED_INTERNAL_HOSTS=<host1>,<host2>,...` — a comma- or whitespace-separated list of bare hostnames or IPs (`10.0.0.5`, `litellm.internal.corp`; a `host:port` or full URL is accepted and reduced to its hostname; IPv6 must be bracketed, e.g. `[fd00::1]`). The allowlist is strict opt-in (empty by default), exact-host (no subdomain/substring matching), and applies **only** to provider endpoints you configure (connection test, model discovery, BYOK chat). It deliberately does **not** relax the guard on download URLs returned inside upstream responses, which stay blocked. A malformed entry — or CIDR notation, which is not supported — is dropped with a warning rather than silently trusted, so a typo never quietly widens (or fails to widen) the guard. Allowlisting a hostname trusts whatever it resolves to (like `OD_ALLOWED_ORIGINS`); allowlist the resolved IP instead if you want the DNS-resolved address re-checked.
**<a id="internally-hosted-model-endpoints"></a>Internally-hosted model endpoints.** To prevent SSRF, the daemon blocks provider base URLs that resolve to private/internal address ranges (RFC1918, link-local, CGNAT, and cloud-metadata IPs) by default, surfacing `Internal IPs blocked`. If you run an internally-hosted gateway (e.g. LiteLLM or Ollama on a VPN-only `10.x`/`192.168.x` address), opt that host out with `OD_ALLOWED_INTERNAL_HOSTS=<host1>,<host2>,...` — a comma- or whitespace-separated list of bare hostnames or IPs (`10.0.0.5`, `litellm.internal.corp`; a `host:port` or full URL is accepted and reduced to its hostname; IPv6 must be bracketed, e.g. `[fd00::1]`). The allowlist is strict opt-in (empty by default), exact-host (no subdomain/substring matching), and applies **only** to provider endpoints you configure (connection test, model discovery, BYOK chat). It deliberately does **not** relax the guard on download URLs returned inside upstream responses, which stay blocked. A malformed entry — or CIDR notation, which is not supported — is dropped with a warning rather than silently trusted, so a typo never quietly widens (or fails to widen) the guard. Allowlisting a hostname trusts whatever it resolves to (like `OD_ALLOWED_ORIGINS`); allowlist the resolved IP instead if you want the DNS-resolved address re-checked.

---

Expand Down
8 changes: 6 additions & 2 deletions apps/daemon/src/connectionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,15 @@ import {
type ConnectionTestResponse,
type ParsedBaseUrl,
type ProviderTestRequest,
INTERNAL_IP_BLOCKED_SENTINEL,
} from '@open-design/contracts/api/connectionTest';
import { googleGenerateContentUrl } from './integrations/google-models.js';
import { resolveAmrProfile } from './integrations/vela.js';

export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
export {
validateBaseUrl,
INTERNAL_IP_BLOCKED_SENTINEL,
} from '@open-design/contracts/api/connectionTest';

// DNS-aware companion to `validateBaseUrl`. The contracts-side check only
// inspects the literal hostname string, so a public DNS name pointing at
Expand Down Expand Up @@ -145,7 +149,7 @@ export async function validateBaseUrlResolved(
// space is still blocked.
if (isAllowlistedInternalHost(ip, options.allowedInternalHosts)) continue;
if (isBlockedExternalApiHostname(ip)) {
return { error: 'Internal IPs blocked', forbidden: true };
return { error: INTERNAL_IP_BLOCKED_SENTINEL, forbidden: true };
}
}

Expand Down
5 changes: 4 additions & 1 deletion apps/daemon/src/integrations/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import type {
ProviderModelsRequest,
ProviderModelsResponse,
} from '@open-design/contracts/api/providerModels';
import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest';
import {
INTERNAL_IP_BLOCKED_SENTINEL,
isLoopbackApiHost,
} from '@open-design/contracts/api/connectionTest';
import { redactSecrets, validateUserProviderBaseUrl } from '../connectionTest.js';
import { googleProviderModelsUrl, normalizeGoogleModelId } from './google-models.js';
import { aihubmixHeaders, aihubmixCatalogUrl, parseAIHubMixCatalog } from './aihubmix.js';
Expand Down
27 changes: 25 additions & 2 deletions apps/web/src/components/EntryShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type RunContextSelection,
type UpsertMemoryRequest,
} from '@open-design/contracts';
import { isInternalIpBlocked } from '@open-design/contracts/api/connectionTest';
import type { OpenDesignHostProjectImportSuccess } from '@open-design/host';
import type { DesignSystemGenerateSnapshot } from './DesignSystemFlow';
import { useAnalytics } from '../analytics/provider';
Expand Down Expand Up @@ -3361,7 +3362,7 @@ function OnboardingByokSetupPanel({
)}`}
role={modelsState.result.ok ? 'status' : 'alert'}
>
{renderOnboardingProviderModelsMessage(t, modelsState.result)}
{renderOnboardingProviderModelsMessage(t, modelsState.result, baseUrl)}
</p>
) : null}
{testState.status === 'running' ? (
Expand All @@ -3375,7 +3376,7 @@ function OnboardingByokSetupPanel({
)}`}
role={testState.result.ok ? 'status' : 'alert'}
>
{renderOnboardingProviderTestMessage(t, testState.result, model)}
{renderOnboardingProviderTestMessage(t, testState.result, model, baseUrl)}
</p>
) : null}
</div>
Expand Down Expand Up @@ -3436,6 +3437,7 @@ function renderOnboardingProviderTestMessage(
t: ReturnType<typeof useT>,
result: ConnectionTestResponse,
fallbackModel: string,
baseUrl?: string,
): string {
const ms = Math.max(0, Math.round(result.latencyMs));
const sample = result.sample ?? '';
Expand All @@ -3448,6 +3450,16 @@ function renderOnboardingProviderTestMessage(
case 'auth_failed':
return t('settings.testAuthFailed');
case 'forbidden':
if (isInternalIpBlocked(result)) {
const entered = (baseUrl || '').trim();
let host = entered;
try {
host = new URL(entered.replace(/\/+$/, '')).hostname;
} catch {
/* keep raw */
}
return t('settings.testInternalIpBlocked', { host: host || entered || 'host' });
}
return t('settings.testForbidden');
case 'not_found_model':
return t('settings.testNotFoundModel', { model: testedModel });
Expand Down Expand Up @@ -3504,6 +3516,7 @@ function renderOnboardingAgentTestMessage(
function renderOnboardingProviderModelsMessage(
t: ReturnType<typeof useT>,
result: ProviderModelsResponse,
baseUrl?: string,
): string {
if (result.ok) {
return t('settings.fetchModelsSuccess', {
Expand All @@ -3514,6 +3527,16 @@ function renderOnboardingProviderModelsMessage(
case 'auth_failed':
return t('settings.testAuthFailed');
case 'forbidden':
if (isInternalIpBlocked(result)) {
const entered = (baseUrl || '').trim();
let host = entered;
try {
host = new URL(entered.replace(/\/+$/, '')).hostname;
} catch {
/* keep raw */
}
return t('settings.testInternalIpBlocked', { host: host || entered || 'host' });
}
return t('settings.testForbidden');
case 'invalid_base_url':
return t('settings.testInvalidBaseUrl');
Expand Down
35 changes: 29 additions & 6 deletions apps/web/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { CSSProperties, Dispatch, SetStateAction } from 'react';
import { Button, VisuallyHidden } from '@open-design/components';
import type { AmrWalletSnapshot } from '@open-design/contracts';
import { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
import {
validateBaseUrl,
isInternalIpBlocked,
} from '@open-design/contracts/api/connectionTest';
import {
agentIdToTracking,
byokProtocolToTracking,
Expand Down Expand Up @@ -2631,6 +2634,16 @@ export function SettingsDialog({
case 'auth_failed':
return t('settings.testAuthFailed');
case 'forbidden':
if (isInternalIpBlocked(result)) {
const entered = (cfg.baseUrl || '').trim();
let host = entered;
try {
host = new URL(entered.replace(/\/+$/, '')).hostname;
} catch {
/* keep raw */
}
return t('settings.testInternalIpBlocked', { host: host || entered || 'host' });
}
return t('settings.testForbidden');
case 'not_found_model':
return t('settings.testNotFoundModel', { model: testedModel });
Expand Down Expand Up @@ -3386,11 +3399,21 @@ export function SettingsDialog({
currentProviderModelsResult.kind === 'auth_failed';
const providerModelsFailureMessage =
currentProviderModelsResult?.ok === false && !apiKeyAuthFailed
? t('settings.fetchModelsFailed', {
detail:
currentProviderModelsResult.detail ||
currentProviderModelsResult.kind,
})
? isInternalIpBlocked(currentProviderModelsResult)
? t('settings.testInternalIpBlocked', {
host: (() => {
try {
return new URL((cfg.baseUrl || '').trim().replace(/\/+$/, '')).hostname;
} catch {
return (cfg.baseUrl || '').trim() || 'host';
}
})(),
})
: t('settings.fetchModelsFailed', {
detail:
currentProviderModelsResult.detail ||
currentProviderModelsResult.kind,
})
: null;
const providerTestBaseUrlInvalid =
providerTestState.status === 'done' &&
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const ar: Dict = {
'settings.testSuccessCli': 'رد {agentName} خلال {ms} مللي ثانية — \'{sample}\'',
'settings.testAuthFailed': 'فشلت المصادقة. تحقق من مفتاح الـ API.',
'settings.testForbidden': 'الوصول ممنوع. تحقق من الحساب أو المنطقة أو المؤسسة.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'لم يتم العثور على النموذج \'{model}\' في هذه النقطة.',
'settings.testInvalidModelId': 'معرّف النموذج \'{model}\' غير صالح. يجب أن يبدأ المعرّف المخصص بحرف أو رقم وألا يحتوي على مسافات.',
'settings.testInvalidBaseUrl': 'Base URL غير صالح أو يتعذر الوصول إليه.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const de: Dict = {
'settings.testSuccessCli': '{agentName} antwortete in {ms} ms — \'{sample}\'',
'settings.testAuthFailed': 'Authentifizierung fehlgeschlagen. Pruefen Sie Ihren API-Schluessel.',
'settings.testForbidden': 'Zugriff verweigert. Pruefen Sie Konto, Region oder Organisation.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'Modell \'{model}\' an diesem Endpunkt nicht gefunden.',
'settings.testInvalidModelId': 'Modell-ID \'{model}\' ist ungueltig. Benutzerdefinierte IDs muessen mit einem Buchstaben oder einer Zahl beginnen und duerfen keine Leerzeichen enthalten.',
'settings.testInvalidBaseUrl': 'Base URL ist ungueltig oder nicht erreichbar.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const en: Dict = {
'settings.testSuccessCli': '{agentName} replied in {ms} ms — \'{sample}\'',
'settings.testAuthFailed': 'Authentication failed. Check your API key.',
'settings.testForbidden': 'Access forbidden. Verify your account, region, or organization.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call sites now compute and pass { host } into settings.testInternalIpBlocked, but this string renders a literal <host> instead of interpolating the actual rejected hostname. The translator only replaces {name} placeholders, so a user who entered http://10.0.0.5:4000 will see OD_ALLOWED_INTERNAL_HOSTS=<host> rather than the concrete host this PR says it should echo. That weakens the main remediation path because the allowlist is exact-host. Please change the source string to include {host} (for example Host {host} is not in the allowlist. Set OD_ALLOWED_INTERNAL_HOSTS={host} ...) and mirror that placeholder in the other locale entries.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

'settings.testNotFoundModel': 'Model \'{model}\' not found on this endpoint.',
'settings.testInvalidModelId': 'Model id \'{model}\' is invalid. Custom ids must start with a letter or number and contain no spaces.',
'settings.testInvalidBaseUrl': 'Base URL is invalid or unreachable.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const esES: Dict = {
'settings.testSuccessCli': '{agentName} respondió en {ms} ms — \'{sample}\'',
'settings.testAuthFailed': 'Autenticación fallida. Comprueba tu clave de API.',
'settings.testForbidden': 'Acceso prohibido. Verifica cuenta, región u organización.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'No se encontró el modelo \'{model}\' en este endpoint.',
'settings.testInvalidModelId': 'El id de modelo \'{model}\' no es válido. Los ids personalizados deben empezar con una letra o un número y no contener espacios.',
'settings.testInvalidBaseUrl': 'La Base URL no es válida o no es accesible.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/fa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const fa: Dict = {
'settings.testSuccessCli': '{agentName} در {ms} میلی‌ثانیه پاسخ داد — \'{sample}\'',
'settings.testAuthFailed': 'احراز هویت ناموفق بود. کلید API را بررسی کنید.',
'settings.testForbidden': 'دسترسی مجاز نیست. حساب، منطقه یا سازمان را بررسی کنید.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'مدل \'{model}\' در این نقطه پایانی یافت نشد.',
'settings.testInvalidModelId': 'شناسه مدل \'{model}\' نامعتبر است. شناسه سفارشی باید با حرف یا عدد شروع شود و فاصله نداشته باشد.',
'settings.testInvalidBaseUrl': 'Base URL نامعتبر یا غیرقابل دسترسی است.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const fr: Dict = {
'settings.testSuccessCli': '{agentName} a répondu en {ms} ms — \'{sample}\'',
'settings.testAuthFailed': 'Échec de l’authentification. Vérifiez votre clé API.',
'settings.testForbidden': 'Accès interdit. Vérifiez le compte, la région ou l’organisation.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'Modèle \'{model}\' introuvable sur ce point d’accès.',
'settings.testInvalidModelId': 'L’id de modèle \'{model}\' est invalide. Les ids personnalisés doivent commencer par une lettre ou un chiffre et ne contenir aucun espace.',
'settings.testInvalidBaseUrl': 'L’URL de base est invalide ou inaccessible.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/hu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const hu: Dict = {
'settings.testSuccessCli': '{agentName} {ms} ms alatt válaszolt — \'{sample}\'',
'settings.testAuthFailed': 'A hitelesítés sikertelen. Ellenőrizd az API kulcsot.',
'settings.testForbidden': 'A hozzáférés tiltott. Ellenőrizd a fiókot, régiót vagy szervezetet.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'A(z) \'{model}\' modell nem található ezen a végponton.',
'settings.testInvalidModelId': 'A(z) \'{model}\' modellazonosító érvénytelen. Az egyéni azonosító betűvel vagy számmal kezdődjön, és ne tartalmazzon szóközt.',
'settings.testInvalidBaseUrl': 'A Base URL érvénytelen vagy nem elérhető.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const id: Dict = {
'settings.testSuccessCli': '{agentName} membalas dalam {ms} md — \'{sample}\'',
'settings.testAuthFailed': 'Autentikasi gagal. Periksa API key kamu.',
'settings.testForbidden': 'Akses ditolak. Verifikasi akun, wilayah, atau organisasi kamu.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'Model \'{model}\' tidak ditemukan di endpoint ini.',
'settings.testInvalidModelId': 'ID model \'{model}\' tidak valid. ID kustom harus diawali huruf atau angka dan tidak boleh berisi spasi.',
'settings.testInvalidBaseUrl': 'Base URL tidak valid atau tidak dapat dijangkau.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const it: Dict = {
'settings.testSuccessCli': '{agentName} ha risposto in {ms} ms — \'{sample}\'',
'settings.testAuthFailed': 'Autenticazione fallita. Controlla la tua chiave API.',
'settings.testForbidden': 'Accesso negato. Controlla account, regione o organizzazione.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'Modello \'{model}\' non trovato su questo endpoint.',
'settings.testInvalidModelId': 'L\'id del modello \'{model}\' non è valido. Gli id personalizzati devono iniziare con una lettera o un numero e non contenere spazi.',
'settings.testInvalidBaseUrl': 'L\'URL di base non è valido o non è accessibile.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const ja: Dict = {
'settings.testSuccessCli': '{agentName} が {ms} ms で応答 — \'{sample}\'',
'settings.testAuthFailed': '認証に失敗しました。API キーを確認してください。',
'settings.testForbidden': 'アクセスが拒否されました。アカウント、リージョン、組織を確認してください。',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': 'このエンドポイントにモデル \'{model}\' は見つかりません。',
'settings.testInvalidModelId': 'モデル ID \'{model}\' は無効です。カスタム ID は英数字で始まり、空白を含めないでください。',
'settings.testInvalidBaseUrl': 'Base URL が無効か到達できません。',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const ko: Dict = {
'settings.testSuccessCli': '{agentName}이(가) {ms} ms 만에 응답 — \'{sample}\'',
'settings.testAuthFailed': '인증 실패. API 키를 확인하세요.',
'settings.testForbidden': '접근이 거부되었습니다. 계정, 지역 또는 조직을 확인하세요.',
'settings.testInternalIpBlocked': 'Internal host blocked for security (SSRF guard). Host not in allowlist. Set OD_ALLOWED_INTERNAL_HOSTS=<host> in the launch environment, then restart the daemon/desktop app. Copy the snippet or see the docs.',
'settings.testNotFoundModel': '이 엔드포인트에서 모델 \'{model}\'을(를) 찾을 수 없습니다.',
'settings.testInvalidModelId': '모델 ID \'{model}\'이(가) 잘못되었습니다. 사용자 지정 ID는 문자 또는 숫자로 시작하고 공백을 포함할 수 없습니다.',
'settings.testInvalidBaseUrl': 'Base URL이 잘못되었거나 접속할 수 없습니다.',
Expand Down
Loading
Loading