fix(security): prefer IPv4 when pinning DB and 1Password connections (#5799)

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).
This commit is contained in:
Waleed
2026-07-20 22:29:58 -07:00
committed by GitHub
parent d381eef93d
commit 3507934427
3 changed files with 28 additions and 5 deletions
@@ -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')
})
})
+4 -1
View File
@@ -317,7 +317,10 @@ export async function validateConnectServerUrl(serverUrl: string): Promise<strin
let address: string
try {
;({ address } = await dns.lookup(clean, { 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(clean, { all: true, verbatim: true })
address = (resolved.find((entry) => entry.family === 4) ?? resolved[0]).address
} catch (error) {
connectLogger.warn('DNS lookup failed for 1Password Connect server URL', {
hostname: clean,
@@ -191,7 +191,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', {