diff --git a/apps/sim/.env.example b/apps/sim/.env.example index d26ff64e52f..4083be6cdb0 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -12,6 +12,9 @@ BETTER_AUTH_URL=http://localhost:3000 # Authentication Bypass (Optional - for self-hosted deployments behind private networks) # DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests. +# Private Database Hosts (Optional - for self-hosted deployments only) +# ALLOW_PRIVATE_DATABASE_HOSTS=true # Uncomment to let database/connector tools reach private/reserved/loopback hosts (e.g. Docker/K8s service names, localhost). Loosens the SSRF boundary; only enable on a trusted private network. + # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL diff --git a/apps/sim/app/api/tools/postgresql/utils.test.ts b/apps/sim/app/api/tools/postgresql/utils.test.ts new file mode 100644 index 00000000000..a02f96b950f --- /dev/null +++ b/apps/sim/app/api/tools/postgresql/utils.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PostgresConnectionConfig } from '@/tools/postgresql/types' + +const { mockValidateDatabaseHost, mockPostgres } = vi.hoisted(() => ({ + mockValidateDatabaseHost: vi.fn(), + mockPostgres: vi.fn(() => ({})), +})) + +vi.mock('postgres', () => ({ default: mockPostgres })) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateDatabaseHost: mockValidateDatabaseHost, +})) + +import { createPostgresConnection } from '@/app/api/tools/postgresql/utils' + +function makeConfig(overrides: Partial = {}): PostgresConnectionConfig { + return { + host: 'db.example.com', + port: 5432, + database: 'app', + username: 'app', + password: 'secret', + ssl: 'required', + ...overrides, + } +} + +describe('createPostgresConnection DNS pinning', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateDatabaseHost.mockResolvedValue({ + isValid: true, + resolvedIP: '93.184.216.34', + originalHostname: 'db.example.com', + }) + }) + + it('never opens a connection when host validation fails (no SSRF window)', async () => { + mockValidateDatabaseHost.mockResolvedValue({ + isValid: false, + error: 'host resolves to a blocked IP address', + }) + + await expect( + createPostgresConnection(makeConfig({ host: 'rebind.attacker.example' })) + ).rejects.toThrow('host resolves to a blocked IP address') + expect(mockPostgres).not.toHaveBeenCalled() + }) + + it.each(['disabled', 'required', 'preferred'] as const)( + 'connects to the validated IP for ssl=%s (hostname never re-resolved)', + async (ssl) => { + await createPostgresConnection(makeConfig({ host: 'rebind.attacker.example', ssl })) + + expect(mockValidateDatabaseHost).toHaveBeenCalledWith('rebind.attacker.example', 'host') + const options = mockPostgres.mock.calls[0][0] + // The TCP target is always the validated IP — re-resolution can never happen. + expect(options.host).toBe('93.184.216.34') + } + ) + + it('preserves the hostname as the TLS servername for verifying ssl modes', async () => { + await createPostgresConnection(makeConfig({ host: 'db.example.com', ssl: 'required' })) + + const options = mockPostgres.mock.calls[0][0] + expect(options.host).toBe('93.184.216.34') + expect(options.ssl).toMatchObject({ servername: 'db.example.com' }) + }) +}) diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts index dfeeab9eadb..983f983288c 100644 --- a/apps/sim/app/api/tools/postgresql/utils.ts +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -9,7 +9,6 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) } const resolvedHost = hostValidation.resolvedIP ?? config.host - const pinIP = config.ssl !== 'preferred' const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } = config.ssl === 'disabled' @@ -19,7 +18,8 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) : { rejectUnauthorized: false, servername: config.host } const sql = postgres({ - host: pinIP ? resolvedHost : config.host, + // Pin the validated IP (never the hostname) to prevent DNS rebinding; SNI stays the hostname above. + host: resolvedHost, port: config.port, database: config.database, username: config.username, diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 63037fd3cb1..ce8aef23e50 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -74,6 +74,35 @@ if (isTruthy(env.DISABLE_AUTH)) { }) } +/** + * Whether database/connector tools may connect to private, reserved, or loopback + * hosts (e.g. Docker/K8s service names, localhost). Off by default: the SSRF guard + * in {@link validateDatabaseHost} blocks these so an untrusted user cannot pivot + * into the deployment's internal network. Self-hosted operators can opt in when + * their database lives on the same private network. Blocked on the hosted platform + * regardless of the env var, mirroring {@link isAuthDisabled}. + */ +export const isPrivateDatabaseHostsAllowed = isTruthy(env.ALLOW_PRIVATE_DATABASE_HOSTS) && !isHosted + +if (isTruthy(env.ALLOW_PRIVATE_DATABASE_HOSTS)) { + import('@sim/logger') + .then(({ createLogger }) => { + const logger = createLogger('EnvFlags') + if (isHosted) { + logger.error( + 'ALLOW_PRIVATE_DATABASE_HOSTS is set but ignored on hosted environment. Private/reserved database hosts remain blocked for security.' + ) + } else { + logger.warn( + 'ALLOW_PRIVATE_DATABASE_HOSTS is enabled. Database/connector tools may reach private, reserved, and loopback hosts. Only use this in trusted private networks.' + ) + } + }) + .catch(() => { + // Fallback during config compilation when logger is unavailable + }) +} + /** * Is user registration disabled */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7bc8eb44d9a..91659f96398 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -33,6 +33,7 @@ export const env = createEnv({ DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Enable email/password authentication (server-side enforcement) DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session) + ALLOW_PRIVATE_DATABASE_HOSTS: z.boolean().optional(), // Opt-in (self-hosted only): let database/connector tools reach private/reserved/loopback hosts (e.g. Docker/K8s service names). Loosens the SSRF boundary; ignored on the hosted platform. ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com") diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index bd344e38997..a81853c5514 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import * as ipaddr from 'ipaddr.js' import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' -import { isHosted } from '@/lib/core/config/env-flags' +import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -152,6 +152,12 @@ export async function validateUrlWithDNS( * database hostnames (e.g. underscores in Docker/K8s service names). It only * blocks localhost and private/reserved IPs. * + * Self-hosted operators can set `ALLOW_PRIVATE_DATABASE_HOSTS` to reach databases + * on their private network (e.g. a Docker/Swarm service name that resolves to an + * internal IP). The opt-in only bypasses the private/reserved/loopback block; DNS + * is still resolved so the caller can pin the connection to the resolved IP. The + * bypass is never honored on the hosted platform (see {@link isPrivateDatabaseHostsAllowed}). + * * @param host - The database hostname to validate * @param paramName - Name of the parameter for error messages * @returns AsyncValidationResult with resolved IP @@ -165,19 +171,25 @@ export async function validateDatabaseHost( } const lowerHost = host.toLowerCase() + const cleanHost = + lowerHost.startsWith('[') && lowerHost.endsWith(']') ? lowerHost.slice(1, -1) : lowerHost - if (lowerHost === 'localhost') { + if (cleanHost === 'localhost' && !isPrivateDatabaseHostsAllowed) { return { isValid: false, error: `${paramName} cannot be localhost` } } - if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) { + if ( + ipaddr.isValid(cleanHost) && + isPrivateOrReservedIP(cleanHost) && + !isPrivateDatabaseHostsAllowed + ) { return { isValid: false, error: `${paramName} cannot be a private IP address` } } try { - const { address } = await dns.lookup(host, { verbatim: true }) + const { address } = await dns.lookup(cleanHost, { verbatim: true }) - if (isPrivateOrReservedIP(address)) { + if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) { logger.warn('Database host resolves to blocked IP address', { paramName, hostname: host, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 7e8be4caa12..e1fbf1f7171 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -28,6 +28,7 @@ import { } from '@/lib/core/security/input-validation' import { isPrivateOrReservedIP, + validateDatabaseHost, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { sanitizeForLogging } from '@/lib/core/security/redaction' @@ -762,6 +763,86 @@ describe('validateUrlWithDNS', () => { }) }) +describe('validateDatabaseHost', () => { + afterEach(() => { + envFlagsMock.isPrivateDatabaseHostsAllowed = false + }) + + describe('default (SSRF guard on)', () => { + it('rejects a missing host', async () => { + const result = await validateDatabaseHost(undefined) + expect(result.isValid).toBe(false) + expect(result.error).toContain('required') + }) + + it('rejects localhost', async () => { + const result = await validateDatabaseHost('localhost') + expect(result.isValid).toBe(false) + expect(result.error).toContain('localhost') + }) + + it('rejects a literal private IP', async () => { + const result = await validateDatabaseHost('10.0.0.5') + expect(result.isValid).toBe(false) + expect(result.error).toContain('private IP') + }) + + it('rejects a literal loopback IP', async () => { + const result = await validateDatabaseHost('127.0.0.1') + expect(result.isValid).toBe(false) + expect(result.error).toContain('private IP') + }) + + it('rejects a bracketed IPv6 loopback as a private IP (not unresolvable)', async () => { + const result = await validateDatabaseHost('[::1]') + expect(result.isValid).toBe(false) + expect(result.error).toContain('private IP') + }) + + it('accepts a public IP and pins the resolved address', async () => { + const result = await validateDatabaseHost('1.1.1.1') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBe('1.1.1.1') + }) + }) + + describe('self-host opt-in (ALLOW_PRIVATE_DATABASE_HOSTS)', () => { + beforeEach(() => { + envFlagsMock.isPrivateDatabaseHostsAllowed = true + }) + + it('allows localhost and still resolves an IP to pin', async () => { + const result = await validateDatabaseHost('localhost') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBeDefined() + }) + + it('allows a literal private IP and pins it', async () => { + const result = await validateDatabaseHost('10.0.0.5') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBe('10.0.0.5') + }) + + it('allows a literal loopback IP and pins it', async () => { + const result = await validateDatabaseHost('127.0.0.1') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBe('127.0.0.1') + }) + + it('allows a bracketed IPv6 loopback and pins the unbracketed address', async () => { + const result = await validateDatabaseHost('[::1]') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBe('::1') + }) + + it('still surfaces unresolvable hostnames', async () => { + const result = await validateDatabaseHost('this-host-does-not-exist.invalid') + expect(result.isValid).toBe(false) + expect(result.error).toContain('could not be resolved') + }) + }) +}) + describe('validateInteger', () => { describe('valid integers', () => { it.concurrent('should accept positive integers', () => { diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 6165c236fae..597d572090a 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -17,6 +17,7 @@ export const envFlagsMock = { isBillingEnabled: false, isEmailVerificationEnabled: false, isAuthDisabled: false, + isPrivateDatabaseHostsAllowed: false, isRegistrationDisabled: false, isEmailPasswordEnabled: false, isTriggerDevEnabled: false,