Skip to content

Commit af87de0

Browse files
authored
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.
1 parent b8e88b1 commit af87de0

8 files changed

Lines changed: 207 additions & 7 deletions

File tree

apps/sim/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ BETTER_AUTH_URL=http://localhost:3000
1212
# Authentication Bypass (Optional - for self-hosted deployments behind private networks)
1313
# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests.
1414

15+
# Private Database Hosts (Optional - for self-hosted deployments only)
16+
# 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.
17+
1518
# NextJS (Required)
1619
NEXT_PUBLIC_APP_URL=http://localhost:3000
1720
# 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
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import type { PostgresConnectionConfig } from '@/tools/postgresql/types'
6+
7+
const { mockValidateDatabaseHost, mockPostgres } = vi.hoisted(() => ({
8+
mockValidateDatabaseHost: vi.fn(),
9+
mockPostgres: vi.fn(() => ({})),
10+
}))
11+
12+
vi.mock('postgres', () => ({ default: mockPostgres }))
13+
14+
vi.mock('@/lib/core/security/input-validation.server', () => ({
15+
validateDatabaseHost: mockValidateDatabaseHost,
16+
}))
17+
18+
import { createPostgresConnection } from '@/app/api/tools/postgresql/utils'
19+
20+
function makeConfig(overrides: Partial<PostgresConnectionConfig> = {}): PostgresConnectionConfig {
21+
return {
22+
host: 'db.example.com',
23+
port: 5432,
24+
database: 'app',
25+
username: 'app',
26+
password: 'secret',
27+
ssl: 'required',
28+
...overrides,
29+
}
30+
}
31+
32+
describe('createPostgresConnection DNS pinning', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
mockValidateDatabaseHost.mockResolvedValue({
36+
isValid: true,
37+
resolvedIP: '93.184.216.34',
38+
originalHostname: 'db.example.com',
39+
})
40+
})
41+
42+
it('never opens a connection when host validation fails (no SSRF window)', async () => {
43+
mockValidateDatabaseHost.mockResolvedValue({
44+
isValid: false,
45+
error: 'host resolves to a blocked IP address',
46+
})
47+
48+
await expect(
49+
createPostgresConnection(makeConfig({ host: 'rebind.attacker.example' }))
50+
).rejects.toThrow('host resolves to a blocked IP address')
51+
expect(mockPostgres).not.toHaveBeenCalled()
52+
})
53+
54+
it.each(['disabled', 'required', 'preferred'] as const)(
55+
'connects to the validated IP for ssl=%s (hostname never re-resolved)',
56+
async (ssl) => {
57+
await createPostgresConnection(makeConfig({ host: 'rebind.attacker.example', ssl }))
58+
59+
expect(mockValidateDatabaseHost).toHaveBeenCalledWith('rebind.attacker.example', 'host')
60+
const options = mockPostgres.mock.calls[0][0]
61+
// The TCP target is always the validated IP — re-resolution can never happen.
62+
expect(options.host).toBe('93.184.216.34')
63+
}
64+
)
65+
66+
it('preserves the hostname as the TLS servername for verifying ssl modes', async () => {
67+
await createPostgresConnection(makeConfig({ host: 'db.example.com', ssl: 'required' }))
68+
69+
const options = mockPostgres.mock.calls[0][0]
70+
expect(options.host).toBe('93.184.216.34')
71+
expect(options.ssl).toMatchObject({ servername: 'db.example.com' })
72+
})
73+
})

apps/sim/app/api/tools/postgresql/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export async function createPostgresConnection(config: PostgresConnectionConfig)
99
}
1010

1111
const resolvedHost = hostValidation.resolvedIP ?? config.host
12-
const pinIP = config.ssl !== 'preferred'
1312

1413
const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } =
1514
config.ssl === 'disabled'
@@ -19,7 +18,8 @@ export async function createPostgresConnection(config: PostgresConnectionConfig)
1918
: { rejectUnauthorized: false, servername: config.host }
2019

2120
const sql = postgres({
22-
host: pinIP ? resolvedHost : config.host,
21+
// Pin the validated IP (never the hostname) to prevent DNS rebinding; SNI stays the hostname above.
22+
host: resolvedHost,
2323
port: config.port,
2424
database: config.database,
2525
username: config.username,

apps/sim/lib/core/config/env-flags.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ if (isTruthy(env.DISABLE_AUTH)) {
7474
})
7575
}
7676

77+
/**
78+
* Whether database/connector tools may connect to private, reserved, or loopback
79+
* hosts (e.g. Docker/K8s service names, localhost). Off by default: the SSRF guard
80+
* in {@link validateDatabaseHost} blocks these so an untrusted user cannot pivot
81+
* into the deployment's internal network. Self-hosted operators can opt in when
82+
* their database lives on the same private network. Blocked on the hosted platform
83+
* regardless of the env var, mirroring {@link isAuthDisabled}.
84+
*/
85+
export const isPrivateDatabaseHostsAllowed = isTruthy(env.ALLOW_PRIVATE_DATABASE_HOSTS) && !isHosted
86+
87+
if (isTruthy(env.ALLOW_PRIVATE_DATABASE_HOSTS)) {
88+
import('@sim/logger')
89+
.then(({ createLogger }) => {
90+
const logger = createLogger('EnvFlags')
91+
if (isHosted) {
92+
logger.error(
93+
'ALLOW_PRIVATE_DATABASE_HOSTS is set but ignored on hosted environment. Private/reserved database hosts remain blocked for security.'
94+
)
95+
} else {
96+
logger.warn(
97+
'ALLOW_PRIVATE_DATABASE_HOSTS is enabled. Database/connector tools may reach private, reserved, and loopback hosts. Only use this in trusted private networks.'
98+
)
99+
}
100+
})
101+
.catch(() => {
102+
// Fallback during config compilation when logger is unavailable
103+
})
104+
}
105+
77106
/**
78107
* Is user registration disabled
79108
*/

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const env = createEnv({
3333
DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration
3434
EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Enable email/password authentication (server-side enforcement)
3535
DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session)
36+
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.
3637
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
3738
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
3839
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger'
66
import { toError } from '@sim/utils/errors'
77
import * as ipaddr from 'ipaddr.js'
88
import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici'
9-
import { isHosted } from '@/lib/core/config/env-flags'
9+
import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags'
1010
import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation'
1111
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1212

@@ -152,6 +152,12 @@ export async function validateUrlWithDNS(
152152
* database hostnames (e.g. underscores in Docker/K8s service names). It only
153153
* blocks localhost and private/reserved IPs.
154154
*
155+
* Self-hosted operators can set `ALLOW_PRIVATE_DATABASE_HOSTS` to reach databases
156+
* on their private network (e.g. a Docker/Swarm service name that resolves to an
157+
* internal IP). The opt-in only bypasses the private/reserved/loopback block; DNS
158+
* is still resolved so the caller can pin the connection to the resolved IP. The
159+
* bypass is never honored on the hosted platform (see {@link isPrivateDatabaseHostsAllowed}).
160+
*
155161
* @param host - The database hostname to validate
156162
* @param paramName - Name of the parameter for error messages
157163
* @returns AsyncValidationResult with resolved IP
@@ -165,19 +171,25 @@ export async function validateDatabaseHost(
165171
}
166172

167173
const lowerHost = host.toLowerCase()
174+
const cleanHost =
175+
lowerHost.startsWith('[') && lowerHost.endsWith(']') ? lowerHost.slice(1, -1) : lowerHost
168176

169-
if (lowerHost === 'localhost') {
177+
if (cleanHost === 'localhost' && !isPrivateDatabaseHostsAllowed) {
170178
return { isValid: false, error: `${paramName} cannot be localhost` }
171179
}
172180

173-
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) {
181+
if (
182+
ipaddr.isValid(cleanHost) &&
183+
isPrivateOrReservedIP(cleanHost) &&
184+
!isPrivateDatabaseHostsAllowed
185+
) {
174186
return { isValid: false, error: `${paramName} cannot be a private IP address` }
175187
}
176188

177189
try {
178-
const { address } = await dns.lookup(host, { verbatim: true })
190+
const { address } = await dns.lookup(cleanHost, { verbatim: true })
179191

180-
if (isPrivateOrReservedIP(address)) {
192+
if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) {
181193
logger.warn('Database host resolves to blocked IP address', {
182194
paramName,
183195
hostname: host,

apps/sim/lib/core/security/input-validation.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '@/lib/core/security/input-validation'
2929
import {
3030
isPrivateOrReservedIP,
31+
validateDatabaseHost,
3132
validateUrlWithDNS,
3233
} from '@/lib/core/security/input-validation.server'
3334
import { sanitizeForLogging } from '@/lib/core/security/redaction'
@@ -762,6 +763,86 @@ describe('validateUrlWithDNS', () => {
762763
})
763764
})
764765

766+
describe('validateDatabaseHost', () => {
767+
afterEach(() => {
768+
envFlagsMock.isPrivateDatabaseHostsAllowed = false
769+
})
770+
771+
describe('default (SSRF guard on)', () => {
772+
it('rejects a missing host', async () => {
773+
const result = await validateDatabaseHost(undefined)
774+
expect(result.isValid).toBe(false)
775+
expect(result.error).toContain('required')
776+
})
777+
778+
it('rejects localhost', async () => {
779+
const result = await validateDatabaseHost('localhost')
780+
expect(result.isValid).toBe(false)
781+
expect(result.error).toContain('localhost')
782+
})
783+
784+
it('rejects a literal private IP', async () => {
785+
const result = await validateDatabaseHost('10.0.0.5')
786+
expect(result.isValid).toBe(false)
787+
expect(result.error).toContain('private IP')
788+
})
789+
790+
it('rejects a literal loopback IP', async () => {
791+
const result = await validateDatabaseHost('127.0.0.1')
792+
expect(result.isValid).toBe(false)
793+
expect(result.error).toContain('private IP')
794+
})
795+
796+
it('rejects a bracketed IPv6 loopback as a private IP (not unresolvable)', async () => {
797+
const result = await validateDatabaseHost('[::1]')
798+
expect(result.isValid).toBe(false)
799+
expect(result.error).toContain('private IP')
800+
})
801+
802+
it('accepts a public IP and pins the resolved address', async () => {
803+
const result = await validateDatabaseHost('1.1.1.1')
804+
expect(result.isValid).toBe(true)
805+
expect(result.resolvedIP).toBe('1.1.1.1')
806+
})
807+
})
808+
809+
describe('self-host opt-in (ALLOW_PRIVATE_DATABASE_HOSTS)', () => {
810+
beforeEach(() => {
811+
envFlagsMock.isPrivateDatabaseHostsAllowed = true
812+
})
813+
814+
it('allows localhost and still resolves an IP to pin', async () => {
815+
const result = await validateDatabaseHost('localhost')
816+
expect(result.isValid).toBe(true)
817+
expect(result.resolvedIP).toBeDefined()
818+
})
819+
820+
it('allows a literal private IP and pins it', async () => {
821+
const result = await validateDatabaseHost('10.0.0.5')
822+
expect(result.isValid).toBe(true)
823+
expect(result.resolvedIP).toBe('10.0.0.5')
824+
})
825+
826+
it('allows a literal loopback IP and pins it', async () => {
827+
const result = await validateDatabaseHost('127.0.0.1')
828+
expect(result.isValid).toBe(true)
829+
expect(result.resolvedIP).toBe('127.0.0.1')
830+
})
831+
832+
it('allows a bracketed IPv6 loopback and pins the unbracketed address', async () => {
833+
const result = await validateDatabaseHost('[::1]')
834+
expect(result.isValid).toBe(true)
835+
expect(result.resolvedIP).toBe('::1')
836+
})
837+
838+
it('still surfaces unresolvable hostnames', async () => {
839+
const result = await validateDatabaseHost('this-host-does-not-exist.invalid')
840+
expect(result.isValid).toBe(false)
841+
expect(result.error).toContain('could not be resolved')
842+
})
843+
})
844+
})
845+
765846
describe('validateInteger', () => {
766847
describe('valid integers', () => {
767848
it.concurrent('should accept positive integers', () => {

packages/testing/src/mocks/env-flags.mock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const envFlagsMock = {
1717
isBillingEnabled: false,
1818
isEmailVerificationEnabled: false,
1919
isAuthDisabled: false,
20+
isPrivateDatabaseHostsAllowed: false,
2021
isRegistrationDisabled: false,
2122
isEmailPasswordEnabled: false,
2223
isTriggerDevEnabled: false,

0 commit comments

Comments
 (0)