Skip to content

Commit 30816e2

Browse files
committed
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
1 parent 33f9d64 commit 30816e2

6 files changed

Lines changed: 113 additions & 4 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

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 allowPrivateDatabaseHosts = 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: 10 additions & 4 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 { allowPrivateDatabaseHosts, isHosted } 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 allowPrivateDatabaseHosts}).
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
@@ -166,18 +172,18 @@ export async function validateDatabaseHost(
166172

167173
const lowerHost = host.toLowerCase()
168174

169-
if (lowerHost === 'localhost') {
175+
if (lowerHost === 'localhost' && !allowPrivateDatabaseHosts) {
170176
return { isValid: false, error: `${paramName} cannot be localhost` }
171177
}
172178

173-
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) {
179+
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost) && !allowPrivateDatabaseHosts) {
174180
return { isValid: false, error: `${paramName} cannot be a private IP address` }
175181
}
176182

177183
try {
178184
const { address } = await dns.lookup(host, { verbatim: true })
179185

180-
if (isPrivateOrReservedIP(address)) {
186+
if (isPrivateOrReservedIP(address) && !allowPrivateDatabaseHosts) {
181187
logger.warn('Database host resolves to blocked IP address', {
182188
paramName,
183189
hostname: host,

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

Lines changed: 69 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,74 @@ describe('validateUrlWithDNS', () => {
762763
})
763764
})
764765

766+
describe('validateDatabaseHost', () => {
767+
afterEach(() => {
768+
envFlagsMock.allowPrivateDatabaseHosts = 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('accepts a public IP and pins the resolved address', async () => {
797+
const result = await validateDatabaseHost('1.1.1.1')
798+
expect(result.isValid).toBe(true)
799+
expect(result.resolvedIP).toBe('1.1.1.1')
800+
})
801+
})
802+
803+
describe('self-host opt-in (ALLOW_PRIVATE_DATABASE_HOSTS)', () => {
804+
beforeEach(() => {
805+
envFlagsMock.allowPrivateDatabaseHosts = true
806+
})
807+
808+
it('allows localhost and still resolves an IP to pin', async () => {
809+
const result = await validateDatabaseHost('localhost')
810+
expect(result.isValid).toBe(true)
811+
expect(result.resolvedIP).toBeDefined()
812+
})
813+
814+
it('allows a literal private IP and pins it', async () => {
815+
const result = await validateDatabaseHost('10.0.0.5')
816+
expect(result.isValid).toBe(true)
817+
expect(result.resolvedIP).toBe('10.0.0.5')
818+
})
819+
820+
it('allows a literal loopback IP and pins it', async () => {
821+
const result = await validateDatabaseHost('127.0.0.1')
822+
expect(result.isValid).toBe(true)
823+
expect(result.resolvedIP).toBe('127.0.0.1')
824+
})
825+
826+
it('still surfaces unresolvable hostnames', async () => {
827+
const result = await validateDatabaseHost('this-host-does-not-exist.invalid')
828+
expect(result.isValid).toBe(false)
829+
expect(result.error).toContain('could not be resolved')
830+
})
831+
})
832+
})
833+
765834
describe('validateInteger', () => {
766835
describe('valid integers', () => {
767836
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+
allowPrivateDatabaseHosts: false,
2021
isRegistrationDisabled: false,
2122
isEmailPasswordEnabled: false,
2223
isTriggerDevEnabled: false,

0 commit comments

Comments
 (0)