From c0c41c0186f93450ec4858cd7c5ade821cd321b7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 22:26:09 -0700 Subject: [PATCH] fix(security): prefer IPv4 when pinning DB and 1Password connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../app/api/tools/onepassword/utils.test.ts | 23 ++++++++++++++++--- apps/sim/app/api/tools/onepassword/utils.ts | 5 +++- .../core/security/input-validation.server.ts | 5 +++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/tools/onepassword/utils.test.ts b/apps/sim/app/api/tools/onepassword/utils.test.ts index 4c07ede1323..8d9e9a76672 100644 --- a/apps/sim/app/api/tools/onepassword/utils.test.ts +++ b/apps/sim/app/api/tools/onepassword/utils.test.ts @@ -54,18 +54,35 @@ describe('validateConnectServerUrl', () => { }) it('blocks a hostname that resolves to a private IP', async () => { - mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 }) + mockDnsLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]) await expect(validateConnectServerUrl('https://connect.internal')).rejects.toThrow( 'cannot point to a private or reserved IP address' ) }) it('allows a hostname that resolves to a public IP', async () => { - mockDnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 }) + mockDnsLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe( '93.184.216.34' ) }) + + it('prefers the IPv4 address for a dual-stack host (avoids unreachable IPv6 pin)', async () => { + mockDnsLookup.mockResolvedValue([ + { address: '2606:4700::6810:85e5', family: 6 }, + { address: '93.184.216.34', family: 4 }, + ]) + await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe( + '93.184.216.34' + ) + }) + + it('pins the sole IPv6 address for an IPv6-only host', async () => { + mockDnsLookup.mockResolvedValue([{ address: '2606:4700::6810:85e5', family: 6 }]) + await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe( + '2606:4700::6810:85e5' + ) + }) }) describe('self-hosted deployment', () => { @@ -94,7 +111,7 @@ describe('validateConnectServerUrl', () => { }) it('allows a hostname that resolves to a private IP', async () => { - mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 }) + mockDnsLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]) await expect(validateConnectServerUrl('https://connect.internal')).resolves.toBe('10.1.2.3') }) }) diff --git a/apps/sim/app/api/tools/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts index b78cf0d511c..0ecdd9abe50 100644 --- a/apps/sim/app/api/tools/onepassword/utils.ts +++ b/apps/sim/app/api/tools/onepassword/utils.ts @@ -317,7 +317,10 @@ export async function validateConnectServerUrl(serverUrl: string): Promise entry.family === 4) ?? resolved[0]).address } catch (error) { connectLogger.warn('DNS lookup failed for 1Password Connect server URL', { hostname: clean, diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index cfef31a08d2..f38805fe21a 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -188,7 +188,10 @@ export async function validateDatabaseHost( } try { - const { address } = await dns.lookup(cleanHost, { verbatim: true }) + // Prefer IPv4: pinning strips Happy Eyeballs' fallback, and a pinned IPv6 address hangs + // on IPv4-only egress (e.g. AWS NAT gateways). + const resolved = await dns.lookup(cleanHost, { all: true, verbatim: true }) + const { address } = resolved.find((entry) => entry.family === 4) ?? resolved[0] if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) { logger.warn('Database host resolves to blocked IP address', {