mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-19 09:30:49 +08:00
fix(connectors): allow self-hosted private DB hosts via opt-in flag (#5322)
* fix(connectors): allow self-hosted private DB hosts via opt-in flag Database/connector tools rejected any host resolving to a private/reserved/ loopback IP, blocking the common self-hosted topology where the DB is reached by a Docker/K8s/Swarm service name. Add an opt-in ALLOW_PRIVATE_DATABASE_HOSTS flag that bypasses the private-host block in validateDatabaseHost while still resolving and pinning DNS. Blocked on the hosted platform regardless of the env var, mirroring DISABLE_AUTH. Fixes #4319 * fix(connectors): pin postgres IP in all ssl modes; strip IPv6 brackets Address review on #5322: - validateDatabaseHost now strips surrounding IPv6 brackets before the localhost/private-IP checks and DNS lookup, so a bracketed loopback like [::1] is classified correctly instead of failing as unresolvable. - PostgreSQL connector always connects to the validated, pinned IP (removed the ssl='preferred' carve-out that passed the original hostname and let the driver re-resolve during connection). Matches the MySQL/MongoDB pin pattern. - Add postgres connector pinning tests and bracketed-IPv6 host tests. * fix(connectors): rename flag to isPrivateDatabaseHostsAllowed; trim comment - Rename env-flag const to satisfy the env-flags 'is' prefix CI check (env var ALLOW_PRIVATE_DATABASE_HOSTS is unchanged). - Tighten the postgres pinning comment to a single line.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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> = {}): 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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export const envFlagsMock = {
|
||||
isBillingEnabled: false,
|
||||
isEmailVerificationEnabled: false,
|
||||
isAuthDisabled: false,
|
||||
isPrivateDatabaseHostsAllowed: false,
|
||||
isRegistrationDisabled: false,
|
||||
isEmailPasswordEnabled: false,
|
||||
isTriggerDevEnabled: false,
|
||||
|
||||
Reference in New Issue
Block a user