Skip to content

Commit 90f6e6f

Browse files
committed
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.
1 parent 30816e2 commit 90f6e6f

4 files changed

Lines changed: 94 additions & 5 deletions

File tree

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: 4 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,10 @@ export async function createPostgresConnection(config: PostgresConnectionConfig)
1918
: { rejectUnauthorized: false, servername: config.host }
2019

2120
const sql = postgres({
22-
host: pinIP ? resolvedHost : config.host,
21+
// Always connect to the validated, pinned IP to prevent DNS rebinding between
22+
// host validation and connection. For SSL modes that verify, the original
23+
// hostname is preserved as the TLS servername (SNI) above.
24+
host: resolvedHost,
2325
port: config.port,
2426
database: config.database,
2527
username: config.username,

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,17 +171,19 @@ export async function validateDatabaseHost(
171171
}
172172

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

175-
if (lowerHost === 'localhost' && !allowPrivateDatabaseHosts) {
177+
if (cleanHost === 'localhost' && !allowPrivateDatabaseHosts) {
176178
return { isValid: false, error: `${paramName} cannot be localhost` }
177179
}
178180

179-
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost) && !allowPrivateDatabaseHosts) {
181+
if (ipaddr.isValid(cleanHost) && isPrivateOrReservedIP(cleanHost) && !allowPrivateDatabaseHosts) {
180182
return { isValid: false, error: `${paramName} cannot be a private IP address` }
181183
}
182184

183185
try {
184-
const { address } = await dns.lookup(host, { verbatim: true })
186+
const { address } = await dns.lookup(cleanHost, { verbatim: true })
185187

186188
if (isPrivateOrReservedIP(address) && !allowPrivateDatabaseHosts) {
187189
logger.warn('Database host resolves to blocked IP address', {

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,12 @@ describe('validateDatabaseHost', () => {
793793
expect(result.error).toContain('private IP')
794794
})
795795

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+
796802
it('accepts a public IP and pins the resolved address', async () => {
797803
const result = await validateDatabaseHost('1.1.1.1')
798804
expect(result.isValid).toBe(true)
@@ -823,6 +829,12 @@ describe('validateDatabaseHost', () => {
823829
expect(result.resolvedIP).toBe('127.0.0.1')
824830
})
825831

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+
826838
it('still surfaces unresolvable hostnames', async () => {
827839
const result = await validateDatabaseHost('this-host-does-not-exist.invalid')
828840
expect(result.isValid).toBe(false)

0 commit comments

Comments
 (0)