diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 21ce5202682..401b9dce70d 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -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, @@ -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, diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index cd341806a27..17f2c225a13 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -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' @@ -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 = 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. @@ -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 } @@ -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 } diff --git a/apps/sim/lib/mcp/oauth/probe.ts b/apps/sim/lib/mcp/oauth/probe.ts index d6aac4d19c1..08c975d25a4 100644 --- a/apps/sim/lib/mcp/oauth/probe.ts +++ b/apps/sim/lib/mcp/oauth/probe.ts @@ -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' diff --git a/apps/sim/lib/mcp/oauth/url-validation.ts b/apps/sim/lib/mcp/oauth/url-validation.ts index 81292bbe630..bae6b9aa2a4 100644 --- a/apps/sim/lib/mcp/oauth/url-validation.ts +++ b/apps/sim/lib/mcp/oauth/url-validation.ts @@ -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) { diff --git a/packages/security/package.json b/packages/security/package.json index 9ff2496baf5..afa673f035d 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -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" diff --git a/packages/security/src/hostnames.test.ts b/packages/security/src/hostnames.test.ts new file mode 100644 index 00000000000..e653c13f542 --- /dev/null +++ b/packages/security/src/hostnames.test.ts @@ -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') + }) +}) diff --git a/packages/security/src/hostnames.ts b/packages/security/src/hostnames.ts new file mode 100644 index 00000000000..cd029da8d93 --- /dev/null +++ b/packages/security/src/hostnames.ts @@ -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 = 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)) +} diff --git a/packages/security/src/ssrf.test.ts b/packages/security/src/ssrf.test.ts index 76bfa8d4c10..dfa7d301022 100644 --- a/packages/security/src/ssrf.test.ts +++ b/packages/security/src/ssrf.test.ts @@ -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', () => { @@ -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') - }) -}) diff --git a/packages/security/src/ssrf.ts b/packages/security/src/ssrf.ts index 7ba9027c1ac..930fe3eb70f 100644 --- a/packages/security/src/ssrf.ts +++ b/packages/security/src/ssrf.ts @@ -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 — @@ -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 = 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-