Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { isLoopbackHostname } from '@sim/security/hostnames'
import { getErrorMessage } from '@sim/utils/errors'
import {
keepPreviousData,
Expand All @@ -26,7 +27,6 @@ import {
testMcpServerConnectionContract,
updateMcpServerContract,
} from '@/lib/api/contracts/mcp'
import { isLoopbackHostname } from '@/lib/core/utils/urls'
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared'
import type {
McpAuthType,
Expand Down
16 changes: 3 additions & 13 deletions apps/sim/lib/core/utils/urls.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isLoopbackHostname } from '@sim/security/hostnames'
import { env, getEnv } from '@/lib/core/config/env'
import { isProd } from '@/lib/core/config/env-flags'

Expand Down Expand Up @@ -103,17 +104,6 @@ export function getEmailDomain(): string {

const DEFAULT_SOCKET_URL = 'http://localhost:3002'
const DEFAULT_OLLAMA_URL = 'http://localhost:11434'
export const LOCALHOST_HOSTNAMES: ReadonlySet<string> = new Set([
'localhost',
'127.0.0.1',
'[::1]',
'::1',
])

export function isLoopbackHostname(hostname: string): boolean {
return LOCALHOST_HOSTNAMES.has(hostname)
}

/**
* Parses a comma-separated list of origins (e.g. from a `TRUSTED_ORIGINS` env
* var) into a deduped array of normalized origins. Invalid entries are dropped.
Expand Down Expand Up @@ -152,7 +142,7 @@ export function parseOriginList(
export function isLocalhostUrl(url: string): boolean {
try {
const { hostname } = new URL(url)
return LOCALHOST_HOSTNAMES.has(hostname)
return isLoopbackHostname(hostname)
} catch {
return false
}
Expand Down Expand Up @@ -208,7 +198,7 @@ export function getSocketUrl(): string {
if (explicit) return explicit

const browserOrigin = getBrowserOrigin()
if (browserOrigin && !LOCALHOST_HOSTNAMES.has(new URL(browserOrigin).hostname)) {
if (browserOrigin && !isLoopbackHostname(new URL(browserOrigin).hostname)) {
return browserOrigin
}

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/mcp/oauth/probe.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
import { createLogger } from '@sim/logger'
import { isLoopbackHostname } from '@sim/security/hostnames'
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
import { isLoopbackHostname } from '@/lib/core/utils/urls'
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
import type { McpAuthType } from '@/lib/mcp/types'

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/mcp/oauth/url-validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isLoopbackHostname } from '@/lib/core/utils/urls'
import { isLoopbackHostname } from '@sim/security/hostnames'

export class McpOauthInsecureUrlError extends Error {
constructor(url: string) {
Expand Down
4 changes: 4 additions & 0 deletions packages/security/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
"types": "./src/hmac.ts",
"default": "./src/hmac.ts"
},
"./hostnames": {
"types": "./src/hostnames.ts",
"default": "./src/hostnames.ts"
},
"./ssrf": {
"types": "./src/ssrf.ts",
"default": "./src/ssrf.ts"
Expand Down
29 changes: 29 additions & 0 deletions packages/security/src/hostnames.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { isLoopbackHostname, unwrapIpv6Brackets } from './hostnames'

describe('isLoopbackHostname', () => {
it('matches localhost and the loopback literals, brackets optional', () => {
expect(isLoopbackHostname('localhost')).toBe(true)
expect(isLoopbackHostname('127.0.0.1')).toBe(true)
expect(isLoopbackHostname('::1')).toBe(true)
expect(isLoopbackHostname('[::1]')).toBe(true)
})

it('does not match other loopback-range IPs or public hosts (exact-set only)', () => {
expect(isLoopbackHostname('127.0.0.5')).toBe(false)
expect(isLoopbackHostname('example.com')).toBe(false)
expect(isLoopbackHostname('10.0.0.1')).toBe(false)
})
})

describe('unwrapIpv6Brackets', () => {
it('strips brackets from IPv6 authorities', () => {
expect(unwrapIpv6Brackets('[::1]')).toBe('::1')
expect(unwrapIpv6Brackets('[2606:4700::1111]')).toBe('2606:4700::1111')
})

it('leaves bare hostnames untouched', () => {
expect(unwrapIpv6Brackets('example.com')).toBe('example.com')
expect(unwrapIpv6Brackets('127.0.0.1')).toBe('127.0.0.1')
})
})
28 changes: 28 additions & 0 deletions packages/security/src/hostnames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Pure host-string helpers with no `ipaddr.js` dependency, so client bundles can
* share them without pulling the IP library. The ipaddr-backed classification
* lives in `./ssrf`, which re-exports these for its own consumers.
*/

/**
* Strips the brackets the WHATWG URL parser puts around IPv6 authorities so the
* result can be matched or handed to an IP classifier directly.
*/
export function unwrapIpv6Brackets(host: string): string {
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
}

/**
* Loopback host identifiers permitted to use plain HTTP: `localhost` and the
* canonical loopback IP literals. Compared after stripping IPv6 brackets.
*/
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1'])

/**
* True when a host (name or IP literal, IPv6 brackets optional) is loopback by
* exact match — `localhost`, `127.0.0.1`, or `::1`. For full-range loopback-IP
* classification (e.g. `127.0.0.5`) use `isLoopbackIp` from `./ssrf`.
*/
export function isLoopbackHostname(host: string): boolean {
return LOOPBACK_HOSTNAMES.has(unwrapIpv6Brackets(host))
}
35 changes: 1 addition & 34 deletions packages/security/src/ssrf.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
isLoopbackHostname,
isLoopbackIp,
isPrivateIp,
isPrivateIpHost,
unwrapIpv6Brackets,
} from './ssrf'
import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from './ssrf'

describe('isPrivateIp', () => {
describe('IPv4 private/reserved ranges', () => {
Expand Down Expand Up @@ -181,30 +175,3 @@ describe('isLoopbackIp', () => {
expect(isLoopbackIp('localhost')).toBe(false)
})
})

describe('isLoopbackHostname', () => {
it('matches localhost and the loopback literals, brackets optional', () => {
expect(isLoopbackHostname('localhost')).toBe(true)
expect(isLoopbackHostname('127.0.0.1')).toBe(true)
expect(isLoopbackHostname('::1')).toBe(true)
expect(isLoopbackHostname('[::1]')).toBe(true)
})

it('does not match other loopback-range IPs or public hosts (exact-set only)', () => {
expect(isLoopbackHostname('127.0.0.5')).toBe(false)
expect(isLoopbackHostname('example.com')).toBe(false)
expect(isLoopbackHostname('10.0.0.1')).toBe(false)
})
})

describe('unwrapIpv6Brackets', () => {
it('strips brackets from IPv6 authorities', () => {
expect(unwrapIpv6Brackets('[::1]')).toBe('::1')
expect(unwrapIpv6Brackets('[2606:4700::1111]')).toBe('2606:4700::1111')
})

it('leaves bare hostnames untouched', () => {
expect(unwrapIpv6Brackets('example.com')).toBe('example.com')
expect(unwrapIpv6Brackets('127.0.0.1')).toBe('127.0.0.1')
})
})
26 changes: 4 additions & 22 deletions packages/security/src/ssrf.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import * as ipaddr from 'ipaddr.js'
import { unwrapIpv6Brackets } from './hostnames'

/**
* Strips the brackets the WHATWG URL parser puts around IPv6 authorities so the
* result can be handed straight to {@link isPrivateIp} / {@link isIpLiteral}.
*/
export function unwrapIpv6Brackets(host: string): string {
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
}
// Re-export the pure host helpers so existing `@sim/security/ssrf` consumers
// keep one import site; client code that must avoid ipaddr imports `./hostnames`.
export { isLoopbackHostname, unwrapIpv6Brackets } from './hostnames'

/**
* True when the (bracket-free) host is an IP literal rather than a DNS name —
Expand All @@ -29,21 +26,6 @@ export function isLoopbackIp(ip: string): boolean {
}
}

/**
* Loopback host identifiers permitted to use plain HTTP: `localhost` and the
* canonical loopback IP literals. Compared after stripping IPv6 brackets.
*/
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1'])

/**
* True when a host (name or IP literal, IPv6 brackets optional) is loopback by
* exact match — `localhost`, `127.0.0.1`, or `::1`. For full-range loopback-IP
* classification (e.g. `127.0.0.5`) use {@link isLoopbackIp}.
*/
export function isLoopbackHostname(host: string): boolean {
return LOOPBACK_HOSTNAMES.has(unwrapIpv6Brackets(host))
}

/**
* Classifies an IP address as private or otherwise not routable on the public
* internet — the core SSRF primitive shared by every app that resolves a user-
Expand Down