Skip to content

Commit c0c41c0

Browse files
committed
fix(security): prefer IPv4 when pinning DB and 1Password connections
Same class of bug fixed for MCP in #5798: SSRF pinning forces a single resolved IP, which strips Happy Eyeballs' IPv4 fallback. dns.lookup(verbatim) returns the IPv6 address first for dual-stack hosts, so a database host or 1Password Connect server that is dual-stack gets pinned to IPv6 — unreachable on IPv4-only egress (AWS NAT gateways) and hangs until timeout. validateDatabaseHost and validateConnectServerUrl now resolve all addresses and prefer IPv4; IPv6-only hosts still pin their sole address. SSRF validation of the pinned IP is unchanged (the selected address is the one validated and pinned).
1 parent 48103e9 commit c0c41c0

3 files changed

Lines changed: 28 additions & 5 deletions

File tree

apps/sim/app/api/tools/onepassword/utils.test.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,35 @@ describe('validateConnectServerUrl', () => {
5454
})
5555

5656
it('blocks a hostname that resolves to a private IP', async () => {
57-
mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 })
57+
mockDnsLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }])
5858
await expect(validateConnectServerUrl('https://connect.internal')).rejects.toThrow(
5959
'cannot point to a private or reserved IP address'
6060
)
6161
})
6262

6363
it('allows a hostname that resolves to a public IP', async () => {
64-
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 })
64+
mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
6565
await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe(
6666
'93.184.216.34'
6767
)
6868
})
69+
70+
it('prefers the IPv4 address for a dual-stack host (avoids unreachable IPv6 pin)', async () => {
71+
mockDnsLookup.mockResolvedValue([
72+
{ address: '2606:4700::6810:85e5', family: 6 },
73+
{ address: '93.184.216.34', family: 4 },
74+
])
75+
await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe(
76+
'93.184.216.34'
77+
)
78+
})
79+
80+
it('pins the sole IPv6 address for an IPv6-only host', async () => {
81+
mockDnsLookup.mockResolvedValue([{ address: '2606:4700::6810:85e5', family: 6 }])
82+
await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe(
83+
'2606:4700::6810:85e5'
84+
)
85+
})
6986
})
7087

7188
describe('self-hosted deployment', () => {
@@ -94,7 +111,7 @@ describe('validateConnectServerUrl', () => {
94111
})
95112

96113
it('allows a hostname that resolves to a private IP', async () => {
97-
mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 })
114+
mockDnsLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }])
98115
await expect(validateConnectServerUrl('https://connect.internal')).resolves.toBe('10.1.2.3')
99116
})
100117
})

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,10 @@ export async function validateConnectServerUrl(serverUrl: string): Promise<strin
317317

318318
let address: string
319319
try {
320-
;({ address } = await dns.lookup(clean, { verbatim: true }))
320+
// Prefer IPv4: pinning strips Happy Eyeballs' fallback, and a pinned IPv6 address hangs
321+
// on IPv4-only egress (e.g. AWS NAT gateways).
322+
const resolved = await dns.lookup(clean, { all: true, verbatim: true })
323+
address = (resolved.find((entry) => entry.family === 4) ?? resolved[0]).address
321324
} catch (error) {
322325
connectLogger.warn('DNS lookup failed for 1Password Connect server URL', {
323326
hostname: clean,

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,10 @@ export async function validateDatabaseHost(
188188
}
189189

190190
try {
191-
const { address } = await dns.lookup(cleanHost, { verbatim: true })
191+
// Prefer IPv4: pinning strips Happy Eyeballs' fallback, and a pinned IPv6 address hangs
192+
// on IPv4-only egress (e.g. AWS NAT gateways).
193+
const resolved = await dns.lookup(cleanHost, { all: true, verbatim: true })
194+
const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0]
192195

193196
if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) {
194197
logger.warn('Database host resolves to blocked IP address', {

0 commit comments

Comments
 (0)