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:
Waleed
2026-07-01 11:19:08 -07:00
committed by GitHub
parent b8e88b14b7
commit af87de09a7
8 changed files with 207 additions and 7 deletions
+3
View File
@@ -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' })
})
})
+2 -2
View File
@@ -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,
+29
View File
@@ -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
*/
+1
View File
@@ -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,