diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2959902b8d0..dcf830e148f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -138,7 +138,7 @@ Good fits for the bridge: OS notifications + dock badge on workflow completion, The main Copilot agent can inspect a user-selected local directory in the desktop app through request-local client tools (`local_mount_directory`, `local_list_mounts`, `local_list`, `local_glob`, `local_read`, `local_grep`, `local_stat`, and `local_forget_mount`). This capability is: - **Explicit and read-only:** the native folder picker creates the grant; there are no write/delete/execute operations. -- **Remembered securely:** grants are encrypted in Electron's private app data with OS-backed `safeStorage` and restored with the same opaque URI after a normal app restart. Sandboxed macOS builds also retain the security-scoped bookmark. There is no plaintext fallback: when secure storage is unavailable, the returned mount has `remembered: false` and lasts only for that app session. +- **Remembered securely:** grants are encrypted in Electron's private app data with OS-backed `safeStorage` and restored with the same opaque URI after a normal app restart. (A security-scoped bookmark is stored alongside each grant, but it is a no-op in the current Developer ID build — only the macOS App Sandbox consumes it — and is kept purely for forward-compatibility should a sandboxed/MAS build ever ship.) There is no plaintext fallback: when secure storage is unavailable, the returned mount has `remembered: false` and lasts only for that app session. - **Revocable:** `local_forget_mount` removes one grant. All grants are removed on explicit sign-out or server-origin change so another Sim account or server cannot inherit them. Normal app quit only releases active OS handles and keeps the encrypted grants. - **Opaque:** renderer and model see only `localfs:///...` URIs. Electron resolves every URI, checks lexical and realpath containment, and refuses symlink escapes. - **Desktop-only:** the web app advertises these request-local tools only when `window.simDesktop.localFilesystem` is present. They are available directly to the main agent and are not added to subagent allowlists. diff --git a/apps/desktop/scripts/install-local.ts b/apps/desktop/scripts/install-local.ts index 51ac7fc4f77..9fd895a6686 100644 --- a/apps/desktop/scripts/install-local.ts +++ b/apps/desktop/scripts/install-local.ts @@ -104,14 +104,7 @@ run('bun', ['run', 'build']) // framework), which turns local signing into a multi-minute stall. Local // installs don't need timestamped signatures — only notarized distribution // builds do. -run('bunx', [ - 'electron-builder', - '--mac', - 'dir', - '--publish', - 'never', - '-c.mac.timestamp=none', -]) +run('bunx', ['electron-builder', '--mac', 'dir', '--publish', 'never', '-c.mac.timestamp=none']) const builtApp = RELEASE_DIRS.map((dir) => join(dir, APP_NAME)).find(existsSync) if (!builtApp) { diff --git a/apps/desktop/src/main/browser-agent/cdp.ts b/apps/desktop/src/main/browser-agent/cdp.ts index a0579f71da2..27e50e7d83e 100644 --- a/apps/desktop/src/main/browser-agent/cdp.ts +++ b/apps/desktop/src/main/browser-agent/cdp.ts @@ -28,7 +28,9 @@ export interface CdpCallbacks { onFileChooser: () => void } -let callbacks: CdpCallbacks | null = null +/** Per-tab callbacks, so a background tab's events reach ITS driver, not the + * most-recently-instrumented tab's. */ +const callbacksByContents = new WeakMap() /** Contents already instrumented (attach survives for the tab's lifetime). */ const instrumented = new WeakSet() @@ -42,7 +44,7 @@ async function send( /** Idempotently instruments a tab's WebContents. */ export async function ensureInstrumented(contents: WebContents, cb: CdpCallbacks): Promise { - callbacks = cb + callbacksByContents.set(contents, cb) if (instrumented.has(contents) && contents.debugger.isAttached()) return if (!contents.debugger.isAttached()) { @@ -76,6 +78,7 @@ function handleDebuggerEvent( method: string, params: Record ): void { + const callbacks = callbacksByContents.get(contents) if (method === 'Page.javascriptDialogOpening') { const type = String(params.type ?? 'dialog') const message = String(params.message ?? '').slice(0, 500) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index a8f78f31133..ef29b0adc93 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -22,6 +22,8 @@ import type { BrowserToolName, } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import type { BrowserWindow, WebContents } from 'electron' import * as cdp from '@/main/browser-agent/cdp' import { ToolError } from '@/main/browser-agent/errors' @@ -41,6 +43,7 @@ import { typeIntoElement, } from '@/main/browser-agent/page-functions' import * as session from '@/main/browser-agent/session' +import { checkAgentUrl } from '@/main/browser-agent/url-guard' const logger = createLogger('BrowserAgentDriver') @@ -126,7 +129,7 @@ function instrumentTab(contents: WebContents): void { .then(() => cdp.setColorScheme(contents, session.getBrowserTheme())) .catch((error) => { logger.warn('CDP instrumentation failed', { - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }) }) for (const event of [ @@ -160,7 +163,7 @@ export function initDriver( onTabThemeChanged: (contents, theme) => { void cdp.setColorScheme(contents, theme).catch((error) => { logger.warn('Could not update browser tab theme', { - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }) }) }, @@ -174,10 +177,6 @@ export function initDriver( ) } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - function str(params: Record, key: string): string | undefined { const value = params[key] return typeof value === 'string' && value.length > 0 ? value : undefined @@ -205,10 +204,6 @@ function requireNum(params: Record, key: string): number { return value } -// --------------------------------------------------------------------------- -// Page-function execution -// --------------------------------------------------------------------------- - /** * Serializes a self-contained page function and executes it in the page's * main world with JSON-encoded arguments (Electron's executeJavaScript has no @@ -223,7 +218,7 @@ async function execInPage( try { return (await contents.executeJavaScript(expression, true)) as Result } catch (error) { - const message = error instanceof Error ? error.message : String(error) + const message = getErrorMessage(error) throw new ToolError( `Cannot act on this page (${message}). Browser-internal pages cannot be automated — ` + 'navigate to a regular website first.' @@ -263,10 +258,6 @@ function unwrapPageResult(result: unknown): unknown { return result } -// --------------------------------------------------------------------------- -// Navigation -// --------------------------------------------------------------------------- - function waitForLoadComplete(contents: WebContents, timeoutMs: number): Promise { return new Promise((resolve) => { let settled = false @@ -301,10 +292,6 @@ async function activeElementState(contents: WebContents): Promise) : {} } -// --------------------------------------------------------------------------- -// Takeover -// --------------------------------------------------------------------------- - /** * Hands control to the user: the page is already natively interactive in the * panel, and the chat's takeover tool row shows the reason with a Done chip. @@ -338,10 +325,6 @@ async function runTakeover(): Promise { } } -// --------------------------------------------------------------------------- -// Tool implementations -// --------------------------------------------------------------------------- - async function executeToolInner( tool: BrowserToolName, params: Record @@ -349,8 +332,14 @@ async function executeToolInner( switch (tool) { case 'browser_navigate': { const url = requireStr(params, 'url') - if (!/^https?:\/\//i.test(url)) { - throw new ToolError('URL must be absolute and start with http:// or https://') + // Up-front SSRF check for a clean model-facing error. The partition's + // onBeforeRequest is the actual enforcement seam (it also catches + // page-initiated navigations); this pre-check exists because loadURL's + // rejection is swallowed below, so a blocked nav would otherwise surface + // as a blank page with no error. + const guard = await checkAgentUrl(url) + if (!guard.ok) { + throw new ToolError(guard.error ?? 'That address was blocked.') } const tab = session.ensureTab() const contents = tab.view.webContents @@ -378,12 +367,15 @@ async function executeToolInner( case 'browser_open_tab': { const url = str(params, 'url') + if (url) { + const guard = await checkAgentUrl(url) + if (!guard.ok) { + throw new ToolError(guard.error ?? 'That address was blocked.') + } + } const tab = session.addTab() const contents = tab.view.webContents if (url) { - if (!/^https?:\/\//i.test(url)) { - throw new ToolError('URL must be absolute and start with http:// or https://') - } void contents.loadURL(url).catch(() => {}) const result = await navigationResult(contents) return { tabId: tab.id, ...result } @@ -580,10 +572,6 @@ function withNotices(result: unknown): unknown { return { value: result, notices } } -// --------------------------------------------------------------------------- -// Public entry points -// --------------------------------------------------------------------------- - /** One real browser can only do one thing at a time — serialize tool calls. */ let toolQueue: Promise = Promise.resolve() @@ -623,7 +611,7 @@ export async function executeTool( try { return { ok: true, result: await settled } } catch (error) { - const message = error instanceof Error ? error.message : String(error) + const message = getErrorMessage(error) logger.warn('Browser tool failed', { tool, error: message }) return { ok: false, error: message } } diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index ee2f006f27b..2117b14265f 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -12,6 +12,7 @@ import { getErrorMessage } from '@sim/utils/errors' import type { BrowserWindow, Input, Session, WebContents } from 'electron' import { nativeTheme, WebContentsView } from 'electron' import { registerAgentWebContents } from '@/main/browser-agent/registry' +import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard' const logger = createLogger('BrowserAgentSession') @@ -127,6 +128,31 @@ function configureAgentPartition(ses: Session): void { partitionConfigured = true ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)) ses.setPermissionCheckHandler(() => false) + // SSRF choke point for the agent partition. Document navigations (top-level + + // iframes) get the full DNS-resolving check — the one seam every navigation + // passes through, including page-initiated ones the driver never sees (server + // redirects, link clicks, location.href, meta-refresh) — so an internal host + // can't slip in that way. Subresources take the cheap synchronous literal-IP + // backstop instead of a DNS lookup per asset. + ses.webRequest.onBeforeRequest((details, callback) => { + if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') { + void checkAgentUrl(details.url) + .then((guard) => { + if (!guard.ok) { + logger.warn('Blocked agent document navigation to a private host') + } + callback({ cancel: !guard.ok }) + }) + .catch((error) => { + // Fail closed: an unexpected rejection must cancel, never leave the + // request suspended with no callback. + logger.error('Agent SSRF check failed; cancelling request', { error }) + callback({ cancel: true }) + }) + return + } + callback({ cancel: isBlockedRequestUrl(details.url) }) + }) ses.on('will-download', (_event, item) => { const filename = item.getFilename() const url = item.getURL() @@ -172,7 +198,7 @@ function createTabView(): WebContentsView { void tab.view.webContents.loadURL(details.url).catch(() => {}) } catch (error) { logger.warn('Could not open browser popup in a new tab', { - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }) } } @@ -326,7 +352,7 @@ function capturePanelSnapshot(): void { }) .catch((error) => { logger.warn('Could not capture browser panel snapshot', { - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }) }) } diff --git a/apps/desktop/src/main/browser-agent/url-guard.test.ts b/apps/desktop/src/main/browser-agent/url-guard.test.ts new file mode 100644 index 00000000000..1f81a070965 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/url-guard.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLookup } = vi.hoisted(() => ({ mockLookup: vi.fn() })) + +vi.mock('node:dns/promises', () => ({ + default: { lookup: mockLookup }, +})) + +import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard' + +describe('checkAgentUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]) + }) + + it('rejects non-http(s) schemes without resolving', async () => { + const result = await checkAgentUrl('file:///etc/passwd') + expect(result.ok).toBe(false) + expect(mockLookup).not.toHaveBeenCalled() + }) + + it('rejects malformed URLs', async () => { + expect((await checkAgentUrl('not a url')).ok).toBe(false) + }) + + it('blocks private IP literals without resolving', async () => { + expect((await checkAgentUrl('http://127.0.0.1/')).ok).toBe(false) + expect((await checkAgentUrl('http://169.254.169.254/latest/meta-data')).ok).toBe(false) + expect((await checkAgentUrl('http://10.0.0.5/')).ok).toBe(false) + expect((await checkAgentUrl('http://[::1]/')).ok).toBe(false) + expect(mockLookup).not.toHaveBeenCalled() + }) + + it('allows public IP literals without resolving', async () => { + expect((await checkAgentUrl('https://8.8.8.8/')).ok).toBe(true) + expect(mockLookup).not.toHaveBeenCalled() + }) + + it('allows hostnames that resolve to public addresses', async () => { + const result = await checkAgentUrl('https://example.com/page') + expect(result.ok).toBe(true) + expect(mockLookup).toHaveBeenCalledWith('example.com', { all: true, verbatim: true }) + }) + + it('blocks hostnames that resolve to a private address (DNS rebinding)', async () => { + mockLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]) + expect((await checkAgentUrl('https://rebind.evil.test/')).ok).toBe(false) + }) + + it('blocks when any resolved address is private', async () => { + mockLookup.mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + { address: '192.168.0.9', family: 4 }, + ]) + expect((await checkAgentUrl('https://mixed.test/')).ok).toBe(false) + }) + + it('fails closed when DNS resolution fails', async () => { + mockLookup.mockRejectedValue(new Error('ENOTFOUND')) + expect((await checkAgentUrl('https://nope.invalid/')).ok).toBe(false) + }) + + it('fails closed when the DNS lookup exceeds the deadline', async () => { + vi.useFakeTimers() + try { + mockLookup.mockReturnValue(new Promise(() => {})) // never resolves + const pending = checkAgentUrl('https://slow.test/') + await vi.advanceTimersByTimeAsync(5_000) + expect((await pending).ok).toBe(false) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('isBlockedRequestUrl', () => { + it('blocks literal private/reserved hosts', () => { + expect(isBlockedRequestUrl('http://169.254.169.254/latest/meta-data')).toBe(true) + expect(isBlockedRequestUrl('http://127.0.0.1:8080/x')).toBe(true) + expect(isBlockedRequestUrl('https://[fd00::1]/')).toBe(true) + }) + + it('allows public literals and hostnames (classified at nav time)', () => { + expect(isBlockedRequestUrl('https://8.8.8.8/')).toBe(false) + expect(isBlockedRequestUrl('https://example.com/x')).toBe(false) + }) + + it('does not throw on malformed input', () => { + expect(isBlockedRequestUrl('::::')).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/url-guard.ts b/apps/desktop/src/main/browser-agent/url-guard.ts new file mode 100644 index 00000000000..8664bc86fc6 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/url-guard.ts @@ -0,0 +1,113 @@ +import dns from 'node:dns/promises' +import { createLogger } from '@sim/logger' +import { isIpLiteral, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf' +import { getErrorMessage } from '@sim/utils/errors' +import { parseHttpUrl } from '@/main/navigation' + +const logger = createLogger('BrowserAgentUrlGuard') + +/** Hard deadline on the SSRF DNS lookup so a slow/hung resolver can't suspend + * the check — and the onBeforeRequest callback that awaits it — indefinitely. + * A timeout rejects, which fails closed (blocks) via the caller's catch. */ +const DNS_TIMEOUT_MS = 5_000 + +/** dns.lookup bounded by {@link DNS_TIMEOUT_MS}; the timer is always cleared so a + * won race never leaves a dangling rejection. */ +async function resolveHost(host: string) { + const lookup = dns.lookup(host, { all: true, verbatim: true }) + // If the timeout wins the race the lookup stays pending; swallow its eventual + // settlement so a late rejection can't surface as an unhandled rejection. + lookup.catch(() => {}) + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + lookup, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('DNS lookup timed out')), DNS_TIMEOUT_MS) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +export interface UrlGuardResult { + ok: boolean + error?: string +} + +const OK: UrlGuardResult = { ok: true } +const BLOCKED: UrlGuardResult = { + ok: false, + error: 'That address points to a private or internal network and was blocked.', +} + +/** + * SSRF guard for agent-browser navigation. The embedded browser is a + * general-purpose surface driven by model/tool input, so a navigation to a + * loopback/RFC1918/link-local host (e.g. the `169.254.169.254` cloud-metadata + * endpoint) would let a page's contents be read back through the read/snapshot + * tools. This resolves the host the same way `apps/sim` does for outbound + * fetches and blocks any that land on a private/reserved address. + * + * IP literals are classified directly; hostnames are DNS-resolved and every + * returned address is checked. Resolution failure fails CLOSED (blocks): we + * can't confirm the host is public, and Chromium resolves independently, so it + * could still reach a private address our lookup missed — matching + * `validateUrlWithDNS` in `apps/sim`. The residual DNS-rebinding TOCTOU window + * (our lookup vs Chromium's) is only fully closable with egress firewalling; + * {@link isBlockedRequestUrl} adds a synchronous per-request literal-IP backstop + * for redirects and subresources. + */ +export async function checkAgentUrl(rawUrl: string): Promise { + const url = parseHttpUrl(rawUrl) + if (!url) { + return { ok: false, error: 'URL must be absolute and start with http:// or https://' } + } + + const host = unwrapIpv6Brackets(url.hostname) + + // IP literal: classify directly, no DNS lookup needed. + if (isIpLiteral(host)) { + if (isPrivateIp(host)) { + logger.warn('Blocked agent navigation to private IP literal', { host }) + return BLOCKED + } + return OK + } + + try { + const resolved = await resolveHost(host) + if (resolved.some(({ address }) => isPrivateIp(address))) { + logger.warn('Blocked agent navigation resolving to private IP', { host }) + return BLOCKED + } + } catch (error) { + // Fail closed: an unresolved host can't be confirmed public, and Chromium + // resolves independently, so it could still reach a private address. + logger.warn('Agent navigation host did not resolve; blocking', { + host, + error: getErrorMessage(error), + }) + return { ok: false, error: 'That address could not be resolved.' } + } + + return OK +} + +/** + * Synchronous backstop for the agent partition's `onBeforeRequest`: blocks any + * request whose host is a **literal** private/reserved IP. This is cheap enough + * to run per-request and catches redirects and subresources that target the + * metadata endpoint or an internal IP directly, without the cost of a DNS + * lookup on every subresource. Hostnames pass here (they are classified at + * navigation time by {@link checkAgentUrl}). + */ +export function isBlockedRequestUrl(rawUrl: string): boolean { + try { + // isPrivateIpHost strips IPv6 brackets itself. + return isPrivateIpHost(new URL(rawUrl).hostname) + } catch { + return false + } +} diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 91035d9ef0c..69a4353faf6 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -1,14 +1,12 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/ssrf' const logger = createLogger('DesktopConfig') export const DEFAULT_ORIGIN = 'https://sim.ai' -/** Loopback hostnames that may use plain HTTP (dev + self-host testing). */ -export const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) - export interface WindowBounds { x?: number y?: number @@ -57,7 +55,7 @@ export function validateOriginInput(raw: string): OriginValidation { if (url.protocol === 'https:') { return { ok: true, origin: url.origin } } - if (url.protocol === 'http:' && LOCAL_HOSTNAMES.has(url.hostname)) { + if (url.protocol === 'http:' && isLoopbackHostname(url.hostname)) { return { ok: true, origin: url.origin } } return { ok: false, error: 'Server URL must use HTTPS (HTTP is allowed for localhost only)' } diff --git a/apps/desktop/src/main/csp.test.ts b/apps/desktop/src/main/csp.test.ts new file mode 100644 index 00000000000..1319ca314fb --- /dev/null +++ b/apps/desktop/src/main/csp.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { attachCspFallback, DEFAULT_DESKTOP_CSP } from '@/main/csp' + +type HeadersReceivedHandler = ( + details: { + url: string + resourceType: string + responseHeaders?: Record + }, + callback: (response: { responseHeaders?: Record }) => void +) => void + +function fakeSession() { + let handler: HeadersReceivedHandler | undefined + const ses = { + webRequest: { + onHeadersReceived: vi.fn((h: HeadersReceivedHandler) => { + handler = h + }), + }, + } + return { ses, run: () => handler } +} + +const APP_ORIGIN = 'https://sim.ai' + +describe('attachCspFallback', () => { + let session: ReturnType + + beforeEach(() => { + session = fakeSession() + attachCspFallback( + session.ses as unknown as Parameters[0], + () => APP_ORIGIN + ) + }) + + it('injects the fallback CSP on an app-origin document lacking one', () => { + const cb = vi.fn() + session.run()?.( + { url: `${APP_ORIGIN}/workspace`, resourceType: 'mainFrame', responseHeaders: {} }, + cb + ) + expect(cb).toHaveBeenCalledWith({ + responseHeaders: { 'Content-Security-Policy': [DEFAULT_DESKTOP_CSP] }, + }) + }) + + it('never overrides a server-sent CSP', () => { + const cb = vi.fn() + session.run()?.( + { + url: `${APP_ORIGIN}/workspace`, + resourceType: 'mainFrame', + responseHeaders: { 'content-security-policy': ["default-src 'self'"] }, + }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) + + it('leaves subresources untouched', () => { + const cb = vi.fn() + session.run()?.( + { url: `${APP_ORIGIN}/app.js`, resourceType: 'script', responseHeaders: {} }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) + + it('leaves non-app-origin documents untouched', () => { + const cb = vi.fn() + session.run()?.( + { + url: 'https://accounts.google.com/o/oauth2', + resourceType: 'mainFrame', + responseHeaders: {}, + }, + cb + ) + expect(cb).toHaveBeenCalledWith({}) + }) +}) diff --git a/apps/desktop/src/main/csp.ts b/apps/desktop/src/main/csp.ts new file mode 100644 index 00000000000..5d956f21e26 --- /dev/null +++ b/apps/desktop/src/main/csp.ts @@ -0,0 +1,47 @@ +import { createLogger } from '@sim/logger' +import type { Session } from 'electron' +import { isAppOrigin } from '@/main/navigation' + +const logger = createLogger('DesktopCsp') + +/** + * A minimal, non-drifting Content-Security-Policy applied ONLY to an app-origin + * top-level document whose response ships no CSP of its own. + * + * The hosted web app sends a full, env-aware policy on every response (see + * `apps/sim/lib/core/security/csp.ts`), which the shell can neither import + * (monorepo boundary) nor safely duplicate (it varies by env). This is a + * defense-in-depth backstop for the narrow case where that header is somehow + * absent — a deliberately small subset of the server's own base directives, so + * it can never be stricter than what the app already depends on and cannot + * break embeds or integrations. + */ +export const DEFAULT_DESKTOP_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" + +function hasCspHeader(headers: Record): boolean { + return Object.keys(headers).some((key) => key.toLowerCase() === 'content-security-policy') +} + +/** + * Installs the CSP fallback on a session. Runs on `onHeadersReceived` (a + * distinct event from telemetry-policy's `onBeforeRequest`, so the two coexist) + * and leaves every response untouched except an app-origin main-frame document + * that carries no CSP, which gets {@link DEFAULT_DESKTOP_CSP}. + */ +export function attachCspFallback(ses: Session, appOrigin: () => string): void { + ses.webRequest.onHeadersReceived((details, callback) => { + const headers = details.responseHeaders ?? {} + if ( + details.resourceType === 'mainFrame' && + isAppOrigin(details.url, appOrigin()) && + !hasCspHeader(headers) + ) { + logger.info('Injecting fallback CSP for app document without one') + callback({ + responseHeaders: { ...headers, 'Content-Security-Policy': [DEFAULT_DESKTOP_CSP] }, + }) + return + } + callback({}) + }) +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 229582a00a3..b903f891f9d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,11 +1,12 @@ import { join } from 'node:path' import { createLogger } from '@sim/logger' import type { BrowserWindow } from 'electron' -import { app, net, session } from 'electron' +import { app, crashReporter, net, session } from 'electron' import { initDriver as initBrowserAgentDriver } from '@/main/browser-agent/driver' import { setPanelBounds as setBrowserAgentPanelBounds } from '@/main/browser-agent/session' import { createConfigStore, partitionForOrigin } from '@/main/config' import { attachContextMenu } from '@/main/context-menu' +import { attachCspFallback } from '@/main/csp' import { createDesktopSettingsService } from '@/main/desktop-settings' import { attachDownloadHandling } from '@/main/downloads' import { createAuthFlow, createConnectFlow, createHandoffManager } from '@/main/handoff' @@ -127,6 +128,7 @@ function main(): void { } configuredPartitions.add(partition) setupPermissionHandlers(ses, appOrigin) + attachCspFallback(ses, appOrigin) attachDownloadHandling(ses, events) attachTelemetryPolicy(ses, config.get('blockThirdPartyAnalytics') ?? true) ses.setSpellCheckerLanguages(['en-US']) @@ -388,6 +390,13 @@ if (process.env.SIM_DESKTOP_USER_DATA) { app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) } +// Capture native minidumps for main/renderer/GPU crashes. Local-only: there is +// no crash-ingest backend, so nothing is uploaded — the dumps land under +// userData/Crashpad and the event log records where. Must start before the app +// is ready so Crashpad initializes first. Set after userData so dumps follow +// any test/instance override. +crashReporter.start({ uploadToServer: false, compress: true }) + const gotSingleInstanceLock = app.requestSingleInstanceLock() if (!gotSingleInstanceLock) { app.quit() diff --git a/apps/desktop/src/main/navigation.test.ts b/apps/desktop/src/main/navigation.test.ts index 7a843589fbf..6edfe06592e 100644 --- a/apps/desktop/src/main/navigation.test.ts +++ b/apps/desktop/src/main/navigation.test.ts @@ -139,6 +139,12 @@ describe('classifyWindowOpen', () => { ) }) + it('does not let a cross-origin http URL ride the mcp-oauth frame name in-app', () => { + expect(classifyWindowOpen('http://mcp.example/authorize', 'mcp-oauth-srv1', APP)).toBe( + 'external' + ) + }) + it('collapses internal new-tab opens into the main window', () => { expect(classifyWindowOpen(`${APP}/workspace/ws1/w/wf1`, '', APP)).toBe('popup-internal') }) diff --git a/apps/desktop/src/main/navigation.ts b/apps/desktop/src/main/navigation.ts index d24f6a260f4..eaf7b0b3747 100644 --- a/apps/desktop/src/main/navigation.ts +++ b/apps/desktop/src/main/navigation.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/ssrf' import { shell } from 'electron' -import { LOCAL_HOSTNAMES } from '@/main/config' const logger = createLogger('DesktopNavigation') @@ -56,7 +56,7 @@ const AUTH_SURFACE_PREFIXES: readonly string[] = [ const MCP_POPUP_NAME_PREFIX = 'mcp-oauth-' -function parseHttpUrl(raw: string): URL | null { +export function parseHttpUrl(raw: string): URL | null { try { const url = new URL(raw) if (url.protocol !== 'http:' && url.protocol !== 'https:') { @@ -160,7 +160,11 @@ export function classifyWindowOpen( } return 'popup-internal' } - if (frameName.startsWith(MCP_POPUP_NAME_PREFIX)) { + // The MCP OAuth popup opens the provider's cross-origin authorization URL + // (always https). The frame name is renderer-controlled, so gate the in-app + // popup on https too — an http(s-less) page can never ride the mcp-oauth name + // into an in-app window; it goes to the system browser like any external URL. + if (frameName.startsWith(MCP_POPUP_NAME_PREFIX) && url.protocol === 'https:') { return 'popup-mcp' } return 'external' @@ -203,7 +207,7 @@ export function isSafeExternalUrl(raw: string, allowHttpLocalhost = false): bool return true } if (url.protocol === 'http:') { - return allowHttpLocalhost && LOCAL_HOSTNAMES.has(url.hostname) + return allowHttpLocalhost && isLoopbackHostname(url.hostname) } return false } diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 55ca5908c96..d786cbd3560 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' import type { MenuItemConstructorOptions, NativeImage } from 'electron' import { app, Menu, nativeImage, session, Tray } from 'electron' import { isSafeInternalPath } from '@/main/config' @@ -285,10 +286,7 @@ export function installTray(deps: TrayDeps): TrayHandle | null { const EMPTY_CACHE_POP_GRACE_MS = 600 const popMenu = async () => { if (cachedChats.length === 0) { - await Promise.race([ - refreshChats(), - new Promise((resolve) => setTimeout(resolve, EMPTY_CACHE_POP_GRACE_MS)), - ]) + await Promise.race([refreshChats(), sleep(EMPTY_CACHE_POP_GRACE_MS)]) } if (tray.isDestroyed()) return tray.popUpContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate(deps, cachedChats))) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index fcb1515364d..bebf7fba60c 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -221,12 +221,23 @@ export function checkForUpdatesInteractive(deps: UpdaterDeps): void { deps.events.record('update_check', { manual: true }) try { const { autoUpdater } = require('electron-updater') as typeof import('electron-updater') - void autoUpdater.checkForUpdates().then((result) => { - if (!result || result.updateInfo.version === app.getVersion()) { - void dialog.showMessageBox({ type: 'info', message: 'Sim is up to date' }) - } - }) + void autoUpdater + .checkForUpdates() + .then((result) => { + if (!result || result.updateInfo.version === app.getVersion()) { + void dialog.showMessageBox({ type: 'info', message: 'Sim is up to date' }) + } + }) + .catch((error) => { + // Surface network/manifest/cert failures instead of silently swallowing. + logger.error('Manual update check failed', { error }) + void dialog.showMessageBox({ + type: 'error', + message: 'Could not check for updates', + detail: 'Something went wrong reaching the update server. Try again later.', + }) + }) } catch (error) { - logger.error('Manual update check failed', { error }) + logger.error('Manual update check threw synchronously', { error }) } } diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index ea417887651..ae1455f600f 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -264,7 +264,11 @@ export function createMainWindow(deps: CreateMainWindowDeps): BrowserWindow { if (details.reason === 'clean-exit') { return } - deps.events.record('renderer_gone', { reason: details.reason, exitCode: details.exitCode }) + deps.events.record('renderer_gone', { + reason: details.reason, + exitCode: details.exitCode, + crashDumpDir: app.getPath('crashDumps'), + }) setTimeout(() => { if (win.isDestroyed()) { return diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index 5bec8636f6a..90d5d0b96c0 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -30,6 +30,10 @@ export const app = { dock: { downloadFinished: vi.fn() }, } +export const crashReporter = { + start: vi.fn(), +} + export const shell = { openExternal: vi.fn(() => Promise.resolve()), showItemInFolder: vi.fn(), @@ -177,6 +181,7 @@ function createWebContentsMock() { session: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), + webRequest: { onBeforeRequest: vi.fn() }, on: vi.fn(), }, } diff --git a/apps/sim/app/api/tools/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts index b78cf0d511c..8c132a5c75c 100644 --- a/apps/sim/app/api/tools/onepassword/utils.ts +++ b/apps/sim/app/api/tools/onepassword/utils.ts @@ -12,14 +12,12 @@ import type { Website, } from '@1password/sdk' import { createLogger } from '@sim/logger' +import { isPrivateIp, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/env-flags' -import { - isPrivateOrReservedIP, - secureFetchWithPinnedIP, -} from '@/lib/core/security/input-validation.server' +import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server' /** Connect-format field type strings returned by normalization. */ type ConnectFieldType = @@ -274,7 +272,7 @@ const connectLogger = createLogger('OnePasswordConnect') */ function assertConnectIpAllowed(ip: string, hostname: string): void { if (isHosted) { - if (isPrivateOrReservedIP(ip)) { + if (isPrivateIp(ip)) { connectLogger.warn('1Password Connect server URL resolves to a private or reserved IP', { hostname, resolvedIP: ip, @@ -307,8 +305,7 @@ export async function validateConnectServerUrl(serverUrl: string): Promise o < 0 || o > 255)) return false - const [a, b] = octets - if (a === 10) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 127) return true - if (a === 169 && b === 254) return true - if (a === 0) return true - return false -} - -function isPrivateOrLoopbackIPv6(host: string): boolean { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - if (lower === '::' || lower === '::1') return true - if (/^fc[0-9a-f]{2}:/.test(lower) || /^fd[0-9a-f]{2}:/.test(lower)) return true - if (lower.startsWith('fe80:')) return true - return false -} - /** Validate a URL is https and not pointing to a private/loopback host. */ export function assertSafeExternalUrl(rawUrl: string, label: string): URL { let parsed: URL @@ -147,12 +124,9 @@ export function assertSafeExternalUrl(rawUrl: string, label: string): URL { if (FORBIDDEN_HOSTS.has(host) || FORBIDDEN_HOSTS.has(`[${host}]`)) { throw new Error(`${label} host is not allowed`) } - if (isPrivateIPv4(host)) { + if (isPrivateIpHost(host)) { throw new Error(`${label} host is not allowed (private/loopback range)`) } - if (isPrivateOrLoopbackIPv6(host)) { - throw new Error(`${label} host is not allowed (IPv6 private/loopback)`) - } return parsed } diff --git a/apps/sim/app/api/tools/zoominfo/shared.ts b/apps/sim/app/api/tools/zoominfo/shared.ts index 0b9f4674f06..15a3092327f 100644 --- a/apps/sim/app/api/tools/zoominfo/shared.ts +++ b/apps/sim/app/api/tools/zoominfo/shared.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' +import { isPrivateIpHost } from '@sim/security/ssrf' import { z } from 'zod' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' @@ -52,21 +53,6 @@ const FORBIDDEN_HOSTS = new Set([ '[::]', ]) -function isPrivateIPv4(host: string): boolean { - const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) - if (!match) return false - const octets = match.slice(1, 5).map(Number) as [number, number, number, number] - if (octets.some((o) => o < 0 || o > 255)) return false - const [a, b] = octets - if (a === 10) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 127) return true - if (a === 169 && b === 254) return true - if (a === 0) return true - return false -} - export function assertSafeZoomInfoUrl(rawUrl: string, label: string): URL { let parsed: URL try { @@ -81,7 +67,7 @@ export function assertSafeZoomInfoUrl(rawUrl: string, label: string): URL { if (FORBIDDEN_HOSTS.has(host)) { throw new Error(`${label} host is not allowed`) } - if (isPrivateIPv4(host)) { + if (isPrivateIpHost(host)) { throw new Error(`${label} host is not allowed (private/loopback range)`) } if (host !== 'api.zoominfo.com') { diff --git a/apps/sim/app/desktop/launcher/use-launcher-chat.ts b/apps/sim/app/desktop/launcher/use-launcher-chat.ts index be03a6614d7..825a3b1c19e 100644 --- a/apps/sim/app/desktop/launcher/use-launcher-chat.ts +++ b/apps/sim/app/desktop/launcher/use-launcher-chat.ts @@ -226,6 +226,7 @@ export function useLauncherChat() { logger.info('Skipping unrecognized stream event', { reason: parsed.reason }) return undefined } + // double-cast-allowed: parsePersistedStreamEventEnvelope validates the envelope structurally; EnvelopeLike is the local narrowed view for this read-only consumer const event = parsed.event as unknown as EnvelopeLike if (event.type === 'session' && event.payload.kind === 'chat') { diff --git a/apps/sim/connectors/s3/s3.ts b/apps/sim/connectors/s3/s3.ts index 8f92d0687d5..ced6a92dc16 100644 --- a/apps/sim/connectors/s3/s3.ts +++ b/apps/sim/connectors/s3/s3.ts @@ -1,5 +1,6 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { isLoopbackHostname } from '@sim/security/ssrf' import { getErrorMessage, toError } from '@sim/utils/errors' import { isHosted } from '@/lib/core/config/env-flags' import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' @@ -122,18 +123,6 @@ function isSupportedKey(key: string, allowedExtensions: Set): boolean { return allowedExtensions.has(getExtension(key)) } -/** - * Returns true when the host is a loopback address for which plain `http://` is - * tolerated (local MinIO development on a self-hosted deployment). Any other - * host must use `https://`. This is only an early check — the SSRF boundary is - * enforced at request time by {@link secureFetchWithRetry}, which blocks - * loopback/private targets on hosted Sim regardless of what this parser accepts. - */ -function isLoopbackHost(host: string): boolean { - const bare = host.replace(/^\[|\]$/g, '') - return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1' -} - /** * Parses and validates a custom S3-compatible endpoint string. * @@ -173,7 +162,9 @@ function parseEndpoint(raw: string): S3Endpoint { const host = url.hostname if (!host) throw new Error('Endpoint is missing a host') - if (scheme === 'http' && !(isLoopbackHost(host) && !isHosted)) { + // Plain http is tolerated only for loopback on self-host (local MinIO); the + // real SSRF boundary is enforced at request time by secureFetchWithRetry. + if (scheme === 'http' && !(isLoopbackHostname(host) && !isHosted)) { throw new Error( 'Plain http:// endpoints are only allowed for localhost on self-hosted deployments — use https:// otherwise' ) diff --git a/apps/sim/lib/api/contracts/tools/sap.ts b/apps/sim/lib/api/contracts/tools/sap.ts index 38b10cbbecc..dcfa27fcaab 100644 --- a/apps/sim/lib/api/contracts/tools/sap.ts +++ b/apps/sim/lib/api/contracts/tools/sap.ts @@ -1,3 +1,4 @@ +import { isPrivateIpHost } from '@sim/security/ssrf' import { z } from 'zod' import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -49,54 +50,6 @@ const FORBIDDEN_SAP_HOSTS = new Set([ '[fd00:ec2::254]', ]) -function isPrivateIPv4(host: string): boolean { - const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) - if (!match) return false - const octets = match.slice(1, 5).map(Number) as [number, number, number, number] - if (octets.some((octet) => octet < 0 || octet > 255)) return false - const [a, b] = octets - if (a === 10) return true - if (a === 172 && b >= 16 && b <= 31) return true - if (a === 192 && b === 168) return true - if (a === 127) return true - if (a === 169 && b === 254) return true - if (a === 0) return true - return false -} - -function extractIPv4MappedHost(host: string): string | null { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - for (const prefix of ['::ffff:', '::']) { - if (lower.startsWith(prefix)) { - const candidate = lower.slice(prefix.length) - if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(candidate)) return candidate - } - } - const hexMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) - if (hexMatch) { - const high = Number.parseInt(hexMatch[1] as string, 16) - const low = Number.parseInt(hexMatch[2] as string, 16) - if (high >= 0 && high <= 0xffff && low >= 0 && low <= 0xffff) { - const a = (high >> 8) & 0xff - const b = high & 0xff - const c = (low >> 8) & 0xff - const d = low & 0xff - return `${a}.${b}.${c}.${d}` - } - } - return null -} - -function isPrivateOrLoopbackIPv6(host: string): boolean { - const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host - const lower = stripped.toLowerCase() - if (lower === '::' || lower === '::1') return true - if (/^fc[0-9a-f]{2}:/.test(lower) || /^fd[0-9a-f]{2}:/.test(lower)) return true - if (lower.startsWith('fe80:')) return true - return false -} - export function checkSapExternalUrlSafety( rawUrl: string, label: string @@ -114,16 +67,9 @@ export function checkSapExternalUrlSafety( if (FORBIDDEN_SAP_HOSTS.has(host) || FORBIDDEN_SAP_HOSTS.has(`[${host}]`)) { return { ok: false, message: `${label} host is not allowed` } } - if (isPrivateIPv4(host)) { + if (isPrivateIpHost(host)) { return { ok: false, message: `${label} host is not allowed (private/loopback range)` } } - const mapped = extractIPv4MappedHost(host) - if (mapped && isPrivateIPv4(mapped)) { - return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` } - } - if (isPrivateOrLoopbackIPv6(host)) { - return { ok: false, message: `${label} host is not allowed (IPv6 private/loopback)` } - } return { ok: true, url: parsed } } diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index dc9764990ba..aa7324a69dd 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -3,6 +3,7 @@ import http from 'http' import https from 'https' import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' +import { isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import * as ipaddr from 'ipaddr.js' @@ -21,49 +22,6 @@ export interface AsyncValidationResult extends ValidationResult { originalHostname?: string } -/** - * Checks if an IP address is private or reserved (not routable on the public internet) - * Uses ipaddr.js for robust handling of all IP formats including: - * - Octal notation (0177.0.0.1) - * - Hex notation (0x7f000001) - * - IPv4-mapped IPv6 (::ffff:127.0.0.1) - * - IPv4-compatible IPv6 (::a.b.c.d / ::xxxx:xxxx, RFC 4291 §2.5.5.1, deprecated) - * - Various edge cases that regex patterns miss - */ -export function isPrivateOrReservedIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) { - return true - } - - const addr = ipaddr.process(ip) - const range = addr.range() - - if (range !== 'unicast') { - return true - } - - if (addr.kind() === 'ipv6') { - const v6 = addr as ipaddr.IPv6 - const parts = v6.parts - const firstSixZero = parts.slice(0, 6).every((p) => p === 0) - if (firstSixZero) { - const embedded = ipaddr.fromByteArray([ - (parts[6] >> 8) & 0xff, - parts[6] & 0xff, - (parts[7] >> 8) & 0xff, - parts[7] & 0xff, - ]) - return embedded.range() !== 'unicast' - } - } - - return false - } catch { - return true - } -} - /** * Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding * @@ -91,10 +49,7 @@ export async function validateUrlWithDNS( const hostname = parsedUrl.hostname const hostnameLower = hostname.toLowerCase() - const cleanHostname = - hostnameLower.startsWith('[') && hostnameLower.endsWith(']') - ? hostnameLower.slice(1, -1) - : hostnameLower + const cleanHostname = unwrapIpv6Brackets(hostnameLower) let isLocalhost = cleanHostname === 'localhost' if (ipaddr.isValid(cleanHostname)) { @@ -114,7 +69,7 @@ export async function validateUrlWithDNS( return ip === '127.0.0.1' || ip === '::1' })() - if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) { + if (isPrivateIp(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) { logger.warn('URL resolves to blocked IP address', { paramName, hostname, @@ -171,26 +126,20 @@ export async function validateDatabaseHost( return { isValid: false, error: `${paramName} is required` } } - const lowerHost = host.toLowerCase() - const cleanHost = - lowerHost.startsWith('[') && lowerHost.endsWith(']') ? lowerHost.slice(1, -1) : lowerHost + const cleanHost = unwrapIpv6Brackets(host.toLowerCase()) if (cleanHost === 'localhost' && !isPrivateDatabaseHostsAllowed) { return { isValid: false, error: `${paramName} cannot be localhost` } } - if ( - ipaddr.isValid(cleanHost) && - isPrivateOrReservedIP(cleanHost) && - !isPrivateDatabaseHostsAllowed - ) { + if (isPrivateIpHost(cleanHost) && !isPrivateDatabaseHostsAllowed) { return { isValid: false, error: `${paramName} cannot be a private IP address` } } try { const { address } = await dns.lookup(cleanHost, { verbatim: true }) - if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) { + if (isPrivateIp(address) && !isPrivateDatabaseHostsAllowed) { logger.warn('Database host resolves to blocked IP address', { paramName, hostname: host, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index b4c87e09374..a5358113d17 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -27,7 +27,6 @@ import { validateWorkdayTenantUrl, } from '@/lib/core/security/input-validation' import { - isPrivateOrReservedIP, validateDatabaseHost, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' @@ -566,147 +565,6 @@ describe('sanitizeForLogging', () => { }) }) -describe('isPrivateOrReservedIP', () => { - describe('IPv4 private/reserved ranges', () => { - it.concurrent.each([ - ['192.168.1.1'], - ['192.168.0.0'], - ['10.0.0.1'], - ['10.255.255.255'], - ['172.16.0.1'], - ['172.31.255.255'], - ['127.0.0.1'], - ['127.255.255.255'], - ['169.254.169.254'], - ['0.0.0.0'], - ['224.0.0.1'], - ])('blocks IPv4 %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('IPv6 reserved ranges', () => { - it.concurrent.each([ - ['::1'], - ['::'], - ['fe80::1'], - ['fc00::1'], - ['fd00::1'], - ['ff02::1'], - ['2001:db8::1'], - ])('blocks IPv6 %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => { - it.concurrent.each([ - ['::ffff:192.168.1.1'], - ['::ffff:127.0.0.1'], - ['::ffff:169.254.169.254'], - ['::ffff:c0a8:101'], - ['::ffff:0:0'], - ])('blocks mapped private/reserved %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - - it.concurrent('allows mapped public IPv4 ::ffff:8.8.8.8', () => { - expect(isPrivateOrReservedIP('::ffff:8.8.8.8')).toBe(false) - }) - }) - - describe('NAT64 (RFC 6052, 64:ff9b::/96)', () => { - it.concurrent('blocks NAT64-encoded private IPv4', () => { - expect(isPrivateOrReservedIP('64:ff9b::192.168.1.1')).toBe(true) - }) - }) - - describe('IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)', () => { - it.concurrent.each([ - ['::c0a8:101', '192.168.1.1 (URL-normalized hex form)'], - ['::c0a8:0101', '192.168.1.1 (zero-padded hex form)'], - ['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'], - ['::7f00:1', '127.0.0.1 (loopback)'], - ['::7f00:0001', '127.0.0.1 (zero-padded)'], - ['::a00:1', '10.0.0.1 (RFC1918)'], - ['::ac10:1', '172.16.0.1 (RFC1918)'], - ['::e000:1', '224.0.0.1 (multicast)'], - ['::192.168.1.1', 'dotted form ::192.168.1.1'], - ['::169.254.169.254', 'dotted form ::169.254.169.254'], - ['::127.0.0.1', 'dotted form ::127.0.0.1'], - ['::10.0.0.1', 'dotted form ::10.0.0.1'], - ])('blocks %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - - it.concurrent.each([ - ['::8.8.8.8', 'dotted form embedding public IPv4'], - ['::808:808', 'hex form embedding 8.8.8.8'], - ['::0808:0808', 'zero-padded hex form embedding 8.8.8.8'], - ])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - - it.concurrent.each([ - ['::ffff:1', 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff'], - ['::ffff:0', 'embedded 255.255.0.0 (Class E reserved)'], - ['::ffff:abcd', 'embedded 255.255.171.205 (Class E reserved)'], - ['::f000:1', 'embedded 240.0.0.1 (Class E reserved)'], - ])('blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => { - it.concurrent.each([ - ['2606:4700:4700::1111'], - ['2001:4860:4860::8888'], - ['::1:c0a8:101'], - ['1::c0a8:101'], - ['1:2:3:4:5:6:c0a8:101'], - ])('allows %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - }) - - describe('IPv4 public addresses', () => { - it.concurrent.each([['8.8.8.8'], ['1.1.1.1'], ['1.0.0.1']])('allows %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(false) - }) - }) - - describe('IPv4 alternate notations', () => { - it.concurrent.each([['0177.0.0.1'], ['0x7f000001']])('blocks loopback notation %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) - - describe('invalid input', () => { - it.concurrent.each([['not-an-ip'], [''], ['256.256.256.256'], ['::g']])('rejects %s', (ip) => { - expect(isPrivateOrReservedIP(ip)).toBe(true) - }) - }) -}) - -describe('URL hostname normalization (Node URL parser + isPrivateOrReservedIP integration)', () => { - it.concurrent('Node normalizes [::192.168.1.1] to [::c0a8:101] and validator blocks it', () => { - const url = new URL('http://[::192.168.1.1]/') - const cleanHostname = - url.hostname.startsWith('[') && url.hostname.endsWith(']') - ? url.hostname.slice(1, -1) - : url.hostname - expect(cleanHostname).toBe('::c0a8:101') - expect(isPrivateOrReservedIP(cleanHostname)).toBe(true) - }) - - it.concurrent('Node normalizes [::169.254.169.254] and validator blocks the metadata IP', () => { - const url = new URL('http://[::169.254.169.254]/') - const cleanHostname = url.hostname.slice(1, -1) - expect(cleanHostname).toBe('::a9fe:a9fe') - expect(isPrivateOrReservedIP(cleanHostname)).toBe(true) - }) -}) - describe('validateUrlWithDNS', () => { describe('basic validation', () => { it('should reject invalid URLs', async () => { diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index fb73b300560..0a1880a1bf9 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isPrivateIp, unwrapIpv6Brackets } from '@sim/security/ssrf' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/env-flags' @@ -401,7 +402,7 @@ export function validateHostname( } if (ipaddr.isValid(lowerHostname)) { - if (isPrivateOrReservedIP(lowerHostname)) { + if (isPrivateIp(lowerHostname)) { logger.warn('Hostname matches blocked IP range', { paramName, hostname: hostname.substring(0, 100), @@ -721,8 +722,7 @@ export function validateExternalUrl( const protocol = parsedUrl.protocol const hostname = parsedUrl.hostname.toLowerCase() - const cleanHostname = - hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + const cleanHostname = unwrapIpv6Brackets(hostname) let isLocalhost = cleanHostname === 'localhost' if (ipaddr.isValid(cleanHostname)) { @@ -754,7 +754,7 @@ export function validateExternalUrl( } if (!isLocalhost && ipaddr.isValid(cleanHostname)) { - if (isPrivateOrReservedIP(cleanHostname)) { + if (isPrivateIp(cleanHostname)) { return { isValid: false, error: `${paramName} cannot point to private IP addresses`, @@ -797,29 +797,6 @@ export function validateProxyUrl( return validateExternalUrl(url, paramName) } -/** - * Checks if an IP address is private or reserved (not routable on the public internet) - * Uses ipaddr.js for robust handling of all IP formats including: - * - Octal notation (0177.0.0.1) - * - Hex notation (0x7f000001) - * - IPv4-mapped IPv6 (::ffff:127.0.0.1) - * - Various edge cases that regex patterns miss - */ -function isPrivateOrReservedIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) { - return true - } - - const addr = ipaddr.process(ip) - const range = addr.range() - - return range !== 'unicast' - } catch { - return true - } -} - /** * Validates an Airtable ID (base, table, or webhook ID) * diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts index 28f7bab6008..261b3fbcedb 100644 --- a/apps/sim/lib/mcp/domain-check.test.ts +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { inputValidationMock, inputValidationMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetAllowedMcpDomainsFromEnv, mockDnsLookup, hostedFlag } = vi.hoisted(() => ({ @@ -17,20 +16,6 @@ vi.mock('@/lib/core/config/env-flags', () => ({ }, })) -vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) - -inputValidationMockFns.mockIsPrivateOrReservedIP.mockImplementation((ip: string) => { - if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true - if (ip.startsWith('172.')) { - const second = Number.parseInt(ip.split('.')[1], 10) - if (second >= 16 && second <= 31) return true - } - if (ip.startsWith('169.254.')) return true - if (ip.startsWith('127.') || ip === '::1') return true - if (ip === '0.0.0.0') return true - return false -}) - vi.mock('dns/promises', () => ({ default: { lookup: mockDnsLookup }, })) diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index d48588ead6b..0b68312230e 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -1,9 +1,8 @@ import dns from 'dns/promises' import { createLogger } from '@sim/logger' +import { isIpLiteral, isLoopbackIp, isPrivateIp, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' -import * as ipaddr from 'ipaddr.js' import { getAllowedMcpDomainsFromEnv, isHosted } from '@/lib/core/config/env-flags' -import { isPrivateOrReservedIP } from '@/lib/core/security/input-validation.server' import { createEnvVarPattern } from '@/executor/utils/reference-validation' const logger = createLogger('McpDomainCheck') @@ -99,25 +98,13 @@ export function validateMcpDomain(url: string | undefined): void { } /** - * Returns true if the IP is a loopback address (full 127.0.0.0/8 range, or ::1). - */ -function isLoopbackIP(ip: string): boolean { - try { - if (!ipaddr.isValid(ip)) return false - return ipaddr.process(ip).range() === 'loopback' - } catch { - return false - } -} - -/** - * Returns true if the hostname is localhost or a loopback IP literal. - * Expects IPv6 brackets to already be stripped. + * Returns true if the hostname is localhost or a loopback IP literal (full + * 127.0.0.0/8 range, or ::1). Expects IPv6 brackets to already be stripped. */ function isLocalhostHostname(hostname: string): boolean { const clean = hostname.toLowerCase() if (clean === 'localhost') return true - return ipaddr.isValid(clean) && isLoopbackIP(clean) + return isLoopbackIp(clean) } /** @@ -162,8 +149,7 @@ export async function validateMcpServerSsrf(url: string | undefined): Promise { + describe('IPv4 private/reserved ranges', () => { + it.each([ + ['192.168.1.1'], + ['192.168.0.0'], + ['10.0.0.1'], + ['10.255.255.255'], + ['172.16.0.1'], + ['172.31.255.255'], + ['127.0.0.1'], + ['127.255.255.255'], + ['169.254.169.254'], + ['0.0.0.0'], + ['224.0.0.1'], + ])('blocks IPv4 %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + }) + + describe('IPv6 reserved ranges', () => { + it.each([['::1'], ['::'], ['fe80::1'], ['fc00::1'], ['fd00::1'], ['ff02::1'], ['2001:db8::1']])( + 'blocks IPv6 %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => { + it.each([ + ['::ffff:192.168.1.1'], + ['::ffff:127.0.0.1'], + ['::ffff:169.254.169.254'], + ['::ffff:c0a8:101'], + ['::ffff:0:0'], + ])('blocks mapped private/reserved %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + + it('allows mapped public IPv4 ::ffff:8.8.8.8', () => { + expect(isPrivateIp('::ffff:8.8.8.8')).toBe(false) + }) + }) + + describe('NAT64 (RFC 6052, 64:ff9b::/96)', () => { + it('blocks NAT64-encoded private IPv4', () => { + expect(isPrivateIp('64:ff9b::192.168.1.1')).toBe(true) + }) + }) + + describe('IPv4-compatible IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1, deprecated)', () => { + it.each([ + ['::c0a8:101', '192.168.1.1 (URL-normalized hex form)'], + ['::c0a8:0101', '192.168.1.1 (zero-padded hex form)'], + ['::a9fe:a9fe', '169.254.169.254 (cloud metadata)'], + ['::7f00:1', '127.0.0.1 (loopback)'], + ['::7f00:0001', '127.0.0.1 (zero-padded)'], + ['::a00:1', '10.0.0.1 (RFC1918)'], + ['::ac10:1', '172.16.0.1 (RFC1918)'], + ['::e000:1', '224.0.0.1 (multicast)'], + ['::192.168.1.1', 'dotted form ::192.168.1.1'], + ['::169.254.169.254', 'dotted form ::169.254.169.254'], + ['::127.0.0.1', 'dotted form ::127.0.0.1'], + ['::10.0.0.1', 'dotted form ::10.0.0.1'], + ])('blocks %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + + it.each([ + ['::8.8.8.8', 'dotted form embedding public IPv4'], + ['::808:808', 'hex form embedding 8.8.8.8'], + ['::0808:0808', 'zero-padded hex form embedding 8.8.8.8'], + ])('allows IPv4-compatible IPv6 with embedded public IPv4 %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + + it.each([ + ['::ffff:1', 'embedded 255.255.0.1 (Class E reserved) via parts[6]=0xffff'], + ['::ffff:0', 'embedded 255.255.0.0 (Class E reserved)'], + ['::ffff:abcd', 'embedded 255.255.171.205 (Class E reserved)'], + ['::f000:1', 'embedded 240.0.0.1 (Class E reserved)'], + ])('blocks IPv4-compatible IPv6 with Class E embedded IPv4 %s — %s', (ip) => { + expect(isPrivateIp(ip)).toBe(true) + }) + }) + + describe('non-IPv4-compat unicast IPv6 (must not over-block)', () => { + it.each([ + ['2606:4700:4700::1111'], + ['2001:4860:4860::8888'], + ['::1:c0a8:101'], + ['1::c0a8:101'], + ['1:2:3:4:5:6:c0a8:101'], + ])('allows %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + }) + + describe('IPv4 public addresses', () => { + it.each([['8.8.8.8'], ['1.1.1.1'], ['1.0.0.1']])('allows %s', (ip) => { + expect(isPrivateIp(ip)).toBe(false) + }) + }) + + describe('IPv4 alternate notations', () => { + it.each([['0177.0.0.1'], ['0x7f000001'], ['2130706433']])( + 'blocks loopback notation %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('invalid input fails closed', () => { + it.each([['not-an-ip'], [''], ['256.256.256.256'], ['::g'], ['example.com']])( + 'rejects %s', + (ip) => { + expect(isPrivateIp(ip)).toBe(true) + } + ) + }) + + describe('URL-parser normalized IPv6 forms', () => { + it('blocks Node-normalized [::192.168.1.1] → ::c0a8:101', () => { + const hostname = new URL('http://[::192.168.1.1]/').hostname + expect(unwrapIpv6Brackets(hostname)).toBe('::c0a8:101') + expect(isPrivateIp(unwrapIpv6Brackets(hostname))).toBe(true) + }) + + it('blocks Node-normalized [::169.254.169.254] → ::a9fe:a9fe', () => { + const hostname = new URL('http://[::169.254.169.254]/').hostname + expect(unwrapIpv6Brackets(hostname)).toBe('::a9fe:a9fe') + expect(isPrivateIp(unwrapIpv6Brackets(hostname))).toBe(true) + }) + }) +}) + +describe('isPrivateIpHost', () => { + it('blocks private/reserved IP literals (IPv4 and IPv6, bracketed or bare)', () => { + expect(isPrivateIpHost('10.0.0.1')).toBe(true) + expect(isPrivateIpHost('169.254.169.254')).toBe(true) + expect(isPrivateIpHost('127.0.0.1')).toBe(true) + expect(isPrivateIpHost('[::1]')).toBe(true) + expect(isPrivateIpHost('[fd00:ec2::254]')).toBe(true) + expect(isPrivateIpHost('[::ffff:127.0.0.1]')).toBe(true) + }) + + it('allows public IP literals', () => { + expect(isPrivateIpHost('8.8.8.8')).toBe(false) + expect(isPrivateIpHost('[2606:4700:4700::1111]')).toBe(false) + }) + + it('fails open on DNS names (resolution handled separately)', () => { + expect(isPrivateIpHost('example.com')).toBe(false) + expect(isPrivateIpHost('api.zoominfo.com')).toBe(false) + expect(isPrivateIpHost('localhost')).toBe(false) + }) +}) + +describe('isLoopbackIp', () => { + it('matches the full loopback range and ::1', () => { + expect(isLoopbackIp('127.0.0.1')).toBe(true) + expect(isLoopbackIp('127.0.0.5')).toBe(true) + expect(isLoopbackIp('::1')).toBe(true) + }) + + it('rejects non-loopback and other private ranges', () => { + expect(isLoopbackIp('10.0.0.1')).toBe(false) + expect(isLoopbackIp('8.8.8.8')).toBe(false) + expect(isLoopbackIp('169.254.169.254')).toBe(false) + expect(isLoopbackIp('not-an-ip')).toBe(false) + 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 new file mode 100644 index 00000000000..7ba9027c1ac --- /dev/null +++ b/packages/security/src/ssrf.ts @@ -0,0 +1,110 @@ +import * as ipaddr from 'ipaddr.js' + +/** + * 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 +} + +/** + * True when the (bracket-free) host is an IP literal rather than a DNS name — + * i.e. it can be classified with {@link isPrivateIp} directly, no DNS lookup. + */ +export function isIpLiteral(host: string): boolean { + return ipaddr.isValid(host) +} + +/** + * True when an IP address is loopback (127.0.0.0/8 or ::1). Narrower than + * {@link isPrivateIp}: callers that treat loopback differently from other + * private ranges (e.g. allowing local dev servers on self-host) use this. + */ +export function isLoopbackIp(ip: string): boolean { + try { + return ipaddr.isValid(ip) && ipaddr.process(ip).range() === 'loopback' + } catch { + return false + } +} + +/** + * 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- + * or model-supplied host before connecting to it. + * + * Uses ipaddr.js for robust handling of forms that regex checks miss: + * - Octal (`0177.0.0.1`) and hex (`0x7f000001`) IPv4 + * - IPv4-mapped IPv6 (`::ffff:127.0.0.1`) + * - IPv4-compatible IPv6 (`::a.b.c.d` / `::xxxx:xxxx`, RFC 4291 §2.5.5.1, deprecated) + * - Loopback, link-local (incl. the `169.254.169.254` cloud-metadata endpoint), + * unique-local, multicast, and every other non-`unicast` range + * + * Expects a bare IP (brackets already stripped). Returns `true` (blocked) for + * anything that is not a valid, publicly routable unicast address — including + * unparseable input, so callers fail closed. + */ +export function isPrivateIp(ip: string): boolean { + try { + if (!ipaddr.isValid(ip)) { + return true + } + + const addr = ipaddr.process(ip) + const range = addr.range() + + if (range !== 'unicast') { + return true + } + + if (addr.kind() === 'ipv6') { + const v6 = addr as ipaddr.IPv6 + const parts = v6.parts + const firstSixZero = parts.slice(0, 6).every((p) => p === 0) + if (firstSixZero) { + const embedded = ipaddr.fromByteArray([ + (parts[6] >> 8) & 0xff, + parts[6] & 0xff, + (parts[7] >> 8) & 0xff, + parts[7] & 0xff, + ]) + return embedded.range() !== 'unicast' + } + } + + return false + } catch { + return true + } +} + +/** + * Classifies a URL/host hostname string that may be an IP literal. Returns + * `true` only when the host is a **literal** IP that {@link isPrivateIp} blocks; + * a DNS name (which needs resolution to classify) returns `false`. + * + * Use this for the synchronous "is this host a private IP literal" guard — a + * pre-navigation check, or a per-request subresource filter — where hostnames + * are handled separately by a DNS-resolving check. IPv6 brackets are stripped + * automatically. + */ +export function isPrivateIpHost(host: string): boolean { + const clean = unwrapIpv6Brackets(host) + return isIpLiteral(clean) && isPrivateIp(clean) +} diff --git a/packages/testing/src/mocks/input-validation.mock.ts b/packages/testing/src/mocks/input-validation.mock.ts index a525df9fcfa..9a9159c27e0 100644 --- a/packages/testing/src/mocks/input-validation.mock.ts +++ b/packages/testing/src/mocks/input-validation.mock.ts @@ -16,7 +16,6 @@ export const inputValidationMockFns = { mockValidateDatabaseHost: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), mockSecureFetchWithValidation: vi.fn(), - mockIsPrivateOrReservedIP: vi.fn().mockReturnValue(false), mockCreatePinnedLookup: vi.fn(), } @@ -33,7 +32,6 @@ export const inputValidationMock = { validateDatabaseHost: inputValidationMockFns.mockValidateDatabaseHost, secureFetchWithPinnedIP: inputValidationMockFns.mockSecureFetchWithPinnedIP, secureFetchWithValidation: inputValidationMockFns.mockSecureFetchWithValidation, - isPrivateOrReservedIP: inputValidationMockFns.mockIsPrivateOrReservedIP, createPinnedLookup: inputValidationMockFns.mockCreatePinnedLookup, SecureFetchHeaders: class { headers: Record = {} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 6e44238b79f..dad531101fb 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 964, - zodRoutes: 964, + totalRoutes: 965, + zodRoutes: 965, nonZodRoutes: 0, } as const