Skip to content

Commit 911586a

Browse files
authored
fix(security): block private/reserved IPs for hosted 1Password Connect SSRF (#4818)
* fix(security): block private/reserved IPs for hosted 1Password Connect SSRF * test(security): use real isPrivateOrReservedIP and cover IPv6 edge cases
1 parent a8f86c0 commit 911586a

2 files changed

Lines changed: 156 additions & 20 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockDnsLookup, hostedFlag } = vi.hoisted(() => ({
7+
mockDnsLookup: vi.fn(),
8+
hostedFlag: { value: false },
9+
}))
10+
11+
vi.mock('@/lib/core/config/feature-flags', () => ({
12+
get isHosted() {
13+
return hostedFlag.value
14+
},
15+
}))
16+
17+
vi.mock('dns/promises', () => ({
18+
default: { lookup: mockDnsLookup },
19+
}))
20+
21+
import { validateConnectServerUrl } from '@/app/api/tools/onepassword/utils'
22+
23+
describe('validateConnectServerUrl', () => {
24+
beforeEach(() => {
25+
vi.clearAllMocks()
26+
hostedFlag.value = false
27+
})
28+
29+
it('rejects a non-URL string', async () => {
30+
await expect(validateConnectServerUrl('not a url')).rejects.toThrow('is not a valid URL')
31+
})
32+
33+
describe('hosted deployment', () => {
34+
beforeEach(() => {
35+
hostedFlag.value = true
36+
})
37+
38+
it.each([
39+
['loopback', 'http://127.0.0.1:8080'],
40+
['RFC1918 10.x', 'http://10.0.0.5'],
41+
['RFC1918 192.168.x', 'http://192.168.1.1:8443'],
42+
['RFC1918 172.16.x', 'http://172.16.0.9'],
43+
['link-local metadata', 'http://169.254.169.254'],
44+
['IPv4-mapped IPv6 private', 'http://[::ffff:10.0.0.1]'],
45+
['IPv6 loopback', 'http://[::1]'],
46+
])('blocks %s', async (_label, url) => {
47+
await expect(validateConnectServerUrl(url)).rejects.toThrow(
48+
'cannot point to a private or reserved IP address'
49+
)
50+
})
51+
52+
it('allows a public IP literal', async () => {
53+
await expect(validateConnectServerUrl('https://8.8.8.8')).resolves.toBe('8.8.8.8')
54+
})
55+
56+
it('blocks a hostname that resolves to a private IP', async () => {
57+
mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 })
58+
await expect(validateConnectServerUrl('https://connect.internal')).rejects.toThrow(
59+
'cannot point to a private or reserved IP address'
60+
)
61+
})
62+
63+
it('allows a hostname that resolves to a public IP', async () => {
64+
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 })
65+
await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe(
66+
'93.184.216.34'
67+
)
68+
})
69+
})
70+
71+
describe('self-hosted deployment', () => {
72+
beforeEach(() => {
73+
hostedFlag.value = false
74+
})
75+
76+
it.each([
77+
['loopback', 'http://127.0.0.1:8080', '127.0.0.1'],
78+
['RFC1918 10.x', 'http://10.0.0.5', '10.0.0.5'],
79+
['RFC1918 192.168.x', 'http://192.168.1.1:8443', '192.168.1.1'],
80+
])('allows %s (private Connect server)', async (_label, url, expected) => {
81+
await expect(validateConnectServerUrl(url)).resolves.toBe(expected)
82+
})
83+
84+
it('still blocks link-local metadata', async () => {
85+
await expect(validateConnectServerUrl('http://169.254.169.254')).rejects.toThrow(
86+
'cannot point to a link-local address'
87+
)
88+
})
89+
90+
it('still blocks IPv6 link-local', async () => {
91+
await expect(validateConnectServerUrl('http://[fe80::1]')).rejects.toThrow(
92+
'cannot point to a link-local address'
93+
)
94+
})
95+
96+
it('allows a hostname that resolves to a private IP', async () => {
97+
mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 })
98+
await expect(validateConnectServerUrl('https://connect.internal')).resolves.toBe('10.1.2.3')
99+
})
100+
})
101+
102+
it('rejects when DNS resolution fails', async () => {
103+
mockDnsLookup.mockRejectedValue(new Error('ENOTFOUND'))
104+
await expect(validateConnectServerUrl('https://nope.invalid')).rejects.toThrow(
105+
'could not be resolved'
106+
)
107+
})
108+
})

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

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import type {
1212
import { createLogger } from '@sim/logger'
1313
import { toError } from '@sim/utils/errors'
1414
import * as ipaddr from 'ipaddr.js'
15-
import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
15+
import { isHosted } from '@/lib/core/config/feature-flags'
16+
import {
17+
isPrivateOrReservedIP,
18+
secureFetchWithPinnedIP,
19+
} from '@/lib/core/security/input-validation.server'
1620

1721
/** Connect-format field type strings returned by normalization. */
1822
type ConnectFieldType =
@@ -246,12 +250,44 @@ export async function createOnePasswordClient(serviceAccountToken: string) {
246250
const connectLogger = createLogger('OnePasswordConnect')
247251

248252
/**
249-
* Validates that a Connect server URL does not target cloud metadata endpoints.
250-
* Allows private IPs and localhost since 1Password Connect is designed to be self-hosted.
251-
* Returns the resolved IP for DNS pinning to prevent TOCTOU rebinding.
252-
* @throws Error if the URL is invalid, points to a link-local address, or DNS fails.
253+
* Enforces the SSRF policy for a resolved Connect server IP.
254+
*
255+
* On the hosted service, all private and reserved IPs are blocked — a tenant has
256+
* no legitimate reason to point Connect at the platform's internal network. On
257+
* self-hosted deployments only link-local (cloud metadata) is blocked, since the
258+
* operator controls both the workflows and the network and Connect servers
259+
* legitimately live on private (RFC1918) addresses.
260+
*
261+
* @throws Error if the IP is not permitted under the active policy.
253262
*/
254-
async function validateConnectServerUrl(serverUrl: string): Promise<string> {
263+
function assertConnectIpAllowed(ip: string, hostname: string): void {
264+
if (isHosted) {
265+
if (isPrivateOrReservedIP(ip)) {
266+
connectLogger.warn('1Password Connect server URL resolves to a private or reserved IP', {
267+
hostname,
268+
resolvedIP: ip,
269+
})
270+
throw new Error('1Password server URL cannot point to a private or reserved IP address')
271+
}
272+
return
273+
}
274+
275+
if (ipaddr.isValid(ip) && ipaddr.process(ip).range() === 'linkLocal') {
276+
connectLogger.warn('1Password Connect server URL resolves to a link-local IP', {
277+
hostname,
278+
resolvedIP: ip,
279+
})
280+
throw new Error('1Password server URL cannot point to a link-local address')
281+
}
282+
}
283+
284+
/**
285+
* Validates a Connect server URL against the SSRF policy and returns the resolved
286+
* IP for DNS pinning to prevent TOCTOU rebinding. See {@link assertConnectIpAllowed}
287+
* for the hosted vs. self-hosted policy.
288+
* @throws Error if the URL is invalid, fails the IP policy, or DNS fails.
289+
*/
290+
export async function validateConnectServerUrl(serverUrl: string): Promise<string> {
255291
let hostname: string
256292
try {
257293
hostname = new URL(serverUrl).hostname
@@ -263,31 +299,23 @@ async function validateConnectServerUrl(serverUrl: string): Promise<string> {
263299
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
264300

265301
if (ipaddr.isValid(clean)) {
266-
const addr = ipaddr.process(clean)
267-
if (addr.range() === 'linkLocal') {
268-
throw new Error('1Password server URL cannot point to a link-local address')
269-
}
302+
assertConnectIpAllowed(clean, clean)
270303
return clean
271304
}
272305

306+
let address: string
273307
try {
274-
const { address } = await dns.lookup(clean, { verbatim: true })
275-
if (ipaddr.isValid(address) && ipaddr.process(address).range() === 'linkLocal') {
276-
connectLogger.warn('1Password Connect server URL resolves to link-local IP', {
277-
hostname: clean,
278-
resolvedIP: address,
279-
})
280-
throw new Error('1Password server URL resolves to a link-local address')
281-
}
282-
return address
308+
;({ address } = await dns.lookup(clean, { verbatim: true }))
283309
} catch (error) {
284-
if (error instanceof Error && error.message.startsWith('1Password')) throw error
285310
connectLogger.warn('DNS lookup failed for 1Password Connect server URL', {
286311
hostname: clean,
287312
error: toError(error).message,
288313
})
289314
throw new Error('1Password server URL hostname could not be resolved')
290315
}
316+
317+
assertConnectIpAllowed(address, clean)
318+
return address
291319
}
292320

293321
/** Minimal response shape used by all connectRequest callers. */

0 commit comments

Comments
 (0)