Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions apps/sim/app/api/tools/postgresql/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
4 changes: 2 additions & 2 deletions apps/sim/app/api/tools/postgresql/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions apps/sim/lib/core/config/env-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
22 changes: 17 additions & 5 deletions apps/sim/lib/core/security/input-validation.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions apps/sim/lib/core/security/input-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/testing/src/mocks/env-flags.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const envFlagsMock = {
isBillingEnabled: false,
isEmailVerificationEnabled: false,
isAuthDisabled: false,
isPrivateDatabaseHostsAllowed: false,
isRegistrationDisabled: false,
isEmailPasswordEnabled: false,
isTriggerDevEnabled: false,
Expand Down
Loading