Skip to content

Commit 8c38475

Browse files
committed
feat(desktop): SSRF hardening for the browser agent + review fixes
Shared SSRF primitive: - Extract IP classification into @sim/security/ssrf (isPrivateIp, isPrivateIpHost, isIpLiteral, unwrapIpv6Brackets). Collapses the two divergent isPrivateOrReservedIP copies in apps/sim and the ad-hoc regex copies in sap_concur/zoominfo/sap contracts into one audited, ipaddr.js-based implementation consumed by both apps. All callers migrated; no logic left behind. Browser-agent SSRF guard (apps/desktop): - The agent partition's onBeforeRequest is now the authoritative choke point: document navigations (top-level + iframes) get a full DNS-resolving check — covering page-initiated navigation the driver never sees (redirects, link clicks, location.href, meta-refresh) — and subresources get a cheap synchronous literal-IP backstop. - browser_navigate / browser_open_tab validate up front so the model gets a clean error instead of a silent load failure. - Blocks loopback, RFC1918, link-local/cloud-metadata (169.254.169.254), and obfuscated/IPv4-mapped forms; hostnames are resolved (DNS-rebinding aware). Other hardening: - crashReporter: local-only native minidumps (no backend, uploadToServer:false), crash-dump dir recorded in the event log. - CSP fallback: a minimal, non-drifting policy injected only when an app-origin document response ships no CSP of its own. - window.open (MCP OAuth) gated to https so a renderer-controlled frame name can't ride an http URL into an in-app window. - updater: shared configureAutoUpdater re-asserts channel/allowDowngrade on both the launch and manual-check paths. - handoff: the loopback listener now tears down only after the CSRF state validates, not on any format-valid request (self-DoS guard). - cdp: per-WebContents callbacks so a background tab's events reach its own driver. - config: drop the unreachable bare '::1' loopback entry. - README: correct the App Sandbox / security-scoped-bookmark note for the Developer ID build. Tests: @sim/security/ssrf (67), desktop suite (200), affected apps/sim suites green; type-check + biome clean across desktop, security, and touched sim files.
1 parent e20d836 commit 8c38475

35 files changed

Lines changed: 754 additions & 385 deletions

apps/desktop/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ Good fits for the bridge: OS notifications + dock badge on workflow completion,
137137
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:
138138

139139
- **Explicit and read-only:** the native folder picker creates the grant; there are no write/delete/execute operations.
140-
- **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.
140+
- **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.
141141
- **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.
142142
- **Opaque:** renderer and model see only `localfs://<mount-id>/...` URIs. Electron resolves every URI, checks lexical and realpath containment, and refuses symlink escapes.
143143
- **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.

apps/desktop/src/main/browser-agent/cdp.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export interface CdpCallbacks {
2727
onFileChooser: () => void
2828
}
2929

30-
let callbacks: CdpCallbacks | null = null
30+
/** Per-tab callbacks, so a background tab's events reach ITS driver, not the
31+
* most-recently-instrumented tab's. */
32+
const callbacksByContents = new WeakMap<WebContents, CdpCallbacks>()
3133
/** Contents already instrumented (attach survives for the tab's lifetime). */
3234
const instrumented = new WeakSet<WebContents>()
3335

@@ -41,7 +43,7 @@ async function send<T = unknown>(
4143

4244
/** Idempotently instruments a tab's WebContents. */
4345
export async function ensureInstrumented(contents: WebContents, cb: CdpCallbacks): Promise<void> {
44-
callbacks = cb
46+
callbacksByContents.set(contents, cb)
4547
if (instrumented.has(contents) && contents.debugger.isAttached()) return
4648

4749
if (!contents.debugger.isAttached()) {
@@ -65,6 +67,7 @@ function handleDebuggerEvent(
6567
method: string,
6668
params: Record<string, unknown>
6769
): void {
70+
const callbacks = callbacksByContents.get(contents)
6871
if (method === 'Page.javascriptDialogOpening') {
6972
const type = String(params.type ?? 'dialog')
7073
const message = String(params.message ?? '').slice(0, 500)

apps/desktop/src/main/browser-agent/driver.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
typeIntoElement,
4040
} from '@/main/browser-agent/page-functions'
4141
import * as session from '@/main/browser-agent/session'
42+
import { checkAgentUrl } from '@/main/browser-agent/url-guard'
4243

4344
const logger = createLogger('BrowserAgentDriver')
4445

@@ -492,8 +493,9 @@ async function executeToolInner(
492493
switch (tool) {
493494
case 'browser_navigate': {
494495
const url = requireStr(params, 'url')
495-
if (!/^https?:\/\//i.test(url)) {
496-
throw new ToolError('URL must be absolute and start with http:// or https://')
496+
const guard = await checkAgentUrl(url)
497+
if (!guard.ok) {
498+
throw new ToolError(guard.error ?? 'That address was blocked.')
497499
}
498500
const tab = session.ensureTab()
499501
const contents = tab.view.webContents
@@ -521,12 +523,15 @@ async function executeToolInner(
521523

522524
case 'browser_open_tab': {
523525
const url = str(params, 'url')
526+
if (url) {
527+
const guard = await checkAgentUrl(url)
528+
if (!guard.ok) {
529+
throw new ToolError(guard.error ?? 'That address was blocked.')
530+
}
531+
}
524532
const tab = session.addTab()
525533
const contents = tab.view.webContents
526534
if (url) {
527-
if (!/^https?:\/\//i.test(url)) {
528-
throw new ToolError('URL must be absolute and start with http:// or https://')
529-
}
530535
void contents.loadURL(url).catch(() => {})
531536
const result = await navigationResult(contents)
532537
return { tabId: tab.id, ...result }

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
vi.mock('electron', () => import('@/test/electron-mock'))
44

5+
vi.mock('@/main/browser-agent/url-guard', () => ({
6+
checkAgentUrl: vi.fn(async (url: string) => ({
7+
ok: /^https?:\/\//i.test(url) && !url.includes('169.254.169.254'),
8+
})),
9+
isBlockedRequestUrl: vi.fn((url: string) => url.includes('169.254.169.254')),
10+
}))
11+
512
import { BrowserWindow } from 'electron'
613

714
type SessionModule = typeof import('@/main/browser-agent/session')
@@ -11,6 +18,7 @@ interface MockView {
1118
session: {
1219
setPermissionRequestHandler: ReturnType<typeof vi.fn>
1320
setPermissionCheckHandler: ReturnType<typeof vi.fn>
21+
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
1422
}
1523
setWindowOpenHandler: ReturnType<typeof vi.fn>
1624
loadURL: ReturnType<typeof vi.fn>
@@ -133,6 +141,8 @@ describe('browser-agent session', () => {
133141
const openHandler = contents.setWindowOpenHandler.mock.calls[0][0] as (details: {
134142
url: string
135143
}) => { action: string }
144+
// window.open collapses into the same view; the SSRF check runs on the
145+
// resulting document request in onBeforeRequest, not here.
136146
expect(openHandler({ url: 'https://example.com/popup' })).toEqual({ action: 'deny' })
137147
expect(contents.loadURL).toHaveBeenCalledWith('https://example.com/popup')
138148
// Non-http(s) popups are denied without navigating anywhere.
@@ -141,6 +151,38 @@ describe('browser-agent session', () => {
141151
expect(contents.loadURL).not.toHaveBeenCalled()
142152
})
143153

154+
it('gates agent-partition requests: DNS-checks documents, literal-checks subresources', async () => {
155+
const tab = session.ensureTab()
156+
const contents = (tab.view as unknown as MockView).webContents
157+
const onBeforeRequest = contents.session.webRequest.onBeforeRequest.mock.calls[0][0] as (
158+
details: { url: string; resourceType: string },
159+
callback: (response: { cancel?: boolean }) => void
160+
) => void
161+
162+
// A document navigation to a private host is cancelled by the DNS check.
163+
const blockedDoc = vi.fn()
164+
onBeforeRequest(
165+
{ url: 'http://169.254.169.254/latest/meta-data', resourceType: 'mainFrame' },
166+
blockedDoc
167+
)
168+
await vi.waitFor(() => expect(blockedDoc).toHaveBeenCalledWith({ cancel: true }))
169+
170+
// A document navigation to a public host is allowed.
171+
const allowedDoc = vi.fn()
172+
onBeforeRequest({ url: 'https://example.com/', resourceType: 'subFrame' }, allowedDoc)
173+
await vi.waitFor(() => expect(allowedDoc).toHaveBeenCalledWith({ cancel: false }))
174+
175+
// A subresource to a literal private IP is cancelled synchronously.
176+
const blockedSub = vi.fn()
177+
onBeforeRequest({ url: 'http://169.254.169.254/x.js', resourceType: 'image' }, blockedSub)
178+
expect(blockedSub).toHaveBeenCalledWith({ cancel: true })
179+
180+
// A public subresource passes.
181+
const allowedSub = vi.fn()
182+
onBeforeRequest({ url: 'https://example.com/x.js', resourceType: 'script' }, allowedSub)
183+
expect(allowedSub).toHaveBeenCalledWith({ cancel: false })
184+
})
185+
144186
it('permission handlers deny every request on the agent partition', () => {
145187
const tab = session.ensureTab()
146188
const ses = (tab.view as unknown as MockView).webContents.session

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import type { BrowserWindow, Session, WebContents } from 'electron'
44
import { WebContentsView } from 'electron'
55
import { registerAgentWebContents } from '@/main/browser-agent/registry'
6+
import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard'
67

78
const logger = createLogger('BrowserAgentSession')
89

@@ -68,6 +69,26 @@ function configureAgentPartition(ses: Session): void {
6869
partitionConfigured = true
6970
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
7071
ses.setPermissionCheckHandler(() => false)
72+
// The SSRF choke point for the agent partition. Document navigations
73+
// (top-level + iframes) get the full DNS-resolving check — this is the ONE
74+
// seam every navigation passes through, including the page-initiated ones the
75+
// driver never sees (server redirects, link clicks, location.href,
76+
// meta-refresh), so an internal-resolving hostname can't slip in that way.
77+
// Subresources take the cheap synchronous literal-IP backstop instead of a
78+
// DNS lookup per asset (a hostname subresource is already gated by its
79+
// document's check).
80+
ses.webRequest.onBeforeRequest((details, callback) => {
81+
if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') {
82+
void checkAgentUrl(details.url).then((guard) => {
83+
if (!guard.ok) {
84+
logger.warn('Blocked agent document navigation to a private host')
85+
}
86+
callback({ cancel: !guard.ok })
87+
})
88+
return
89+
}
90+
callback({ cancel: isBlockedRequestUrl(details.url) })
91+
})
7192
ses.on('will-download', (_event, item) => {
7293
const filename = item.getFilename()
7394
const url = item.getURL()
@@ -97,7 +118,9 @@ function createTabView(): WebContentsView {
97118
configureAgentPartition(contents.session)
98119

99120
// Popup-neutralize: window.open and target=_blank navigate the SAME view
100-
// instead of spawning windows — the agent browses one page per tab.
121+
// instead of spawning windows — the agent browses one page per tab. The
122+
// resulting navigation is a document request, so the partition's
123+
// onBeforeRequest SSRF check gates it; no per-call guard is needed here.
101124
contents.setWindowOpenHandler((details) => {
102125
if (/^https?:\/\//i.test(details.url)) {
103126
void contents.loadURL(details.url).catch(() => {})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const { mockLookup } = vi.hoisted(() => ({ mockLookup: vi.fn() }))
4+
5+
vi.mock('node:dns/promises', () => ({
6+
default: { lookup: mockLookup },
7+
}))
8+
9+
import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard'
10+
11+
describe('checkAgentUrl', () => {
12+
beforeEach(() => {
13+
vi.clearAllMocks()
14+
mockLookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
15+
})
16+
17+
it('rejects non-http(s) schemes without resolving', async () => {
18+
const result = await checkAgentUrl('file:///etc/passwd')
19+
expect(result.ok).toBe(false)
20+
expect(mockLookup).not.toHaveBeenCalled()
21+
})
22+
23+
it('rejects malformed URLs', async () => {
24+
expect((await checkAgentUrl('not a url')).ok).toBe(false)
25+
})
26+
27+
it('blocks private IP literals without resolving', async () => {
28+
expect((await checkAgentUrl('http://127.0.0.1/')).ok).toBe(false)
29+
expect((await checkAgentUrl('http://169.254.169.254/latest/meta-data')).ok).toBe(false)
30+
expect((await checkAgentUrl('http://10.0.0.5/')).ok).toBe(false)
31+
expect((await checkAgentUrl('http://[::1]/')).ok).toBe(false)
32+
expect(mockLookup).not.toHaveBeenCalled()
33+
})
34+
35+
it('allows public IP literals without resolving', async () => {
36+
expect((await checkAgentUrl('https://8.8.8.8/')).ok).toBe(true)
37+
expect(mockLookup).not.toHaveBeenCalled()
38+
})
39+
40+
it('allows hostnames that resolve to public addresses', async () => {
41+
const result = await checkAgentUrl('https://example.com/page')
42+
expect(result.ok).toBe(true)
43+
expect(mockLookup).toHaveBeenCalledWith('example.com', { all: true, verbatim: true })
44+
})
45+
46+
it('blocks hostnames that resolve to a private address (DNS rebinding)', async () => {
47+
mockLookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }])
48+
expect((await checkAgentUrl('https://rebind.evil.test/')).ok).toBe(false)
49+
})
50+
51+
it('blocks when any resolved address is private', async () => {
52+
mockLookup.mockResolvedValue([
53+
{ address: '93.184.216.34', family: 4 },
54+
{ address: '192.168.0.9', family: 4 },
55+
])
56+
expect((await checkAgentUrl('https://mixed.test/')).ok).toBe(false)
57+
})
58+
59+
it('allows the load to proceed when DNS resolution fails', async () => {
60+
mockLookup.mockRejectedValue(new Error('ENOTFOUND'))
61+
expect((await checkAgentUrl('https://nope.invalid/')).ok).toBe(true)
62+
})
63+
})
64+
65+
describe('isBlockedRequestUrl', () => {
66+
it('blocks literal private/reserved hosts', () => {
67+
expect(isBlockedRequestUrl('http://169.254.169.254/latest/meta-data')).toBe(true)
68+
expect(isBlockedRequestUrl('http://127.0.0.1:8080/x')).toBe(true)
69+
expect(isBlockedRequestUrl('https://[fd00::1]/')).toBe(true)
70+
})
71+
72+
it('allows public literals and hostnames (classified at nav time)', () => {
73+
expect(isBlockedRequestUrl('https://8.8.8.8/')).toBe(false)
74+
expect(isBlockedRequestUrl('https://example.com/x')).toBe(false)
75+
})
76+
77+
it('does not throw on malformed input', () => {
78+
expect(isBlockedRequestUrl('::::')).toBe(false)
79+
})
80+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import dns from 'node:dns/promises'
2+
import { createLogger } from '@sim/logger'
3+
import { isIpLiteral, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf'
4+
import { getErrorMessage } from '@sim/utils/errors'
5+
import { parseHttpUrl } from '@/main/navigation'
6+
7+
const logger = createLogger('BrowserAgentUrlGuard')
8+
9+
export interface UrlGuardResult {
10+
ok: boolean
11+
error?: string
12+
}
13+
14+
const OK: UrlGuardResult = { ok: true }
15+
const BLOCKED: UrlGuardResult = {
16+
ok: false,
17+
error: 'That address points to a private or internal network and was blocked.',
18+
}
19+
20+
/**
21+
* SSRF guard for agent-browser navigation. The embedded browser is a
22+
* general-purpose surface driven by model/tool input, so a navigation to a
23+
* loopback/RFC1918/link-local host (e.g. the `169.254.169.254` cloud-metadata
24+
* endpoint) would let a page's contents be read back through the read/snapshot
25+
* tools. This resolves the host the same way `apps/sim` does for outbound
26+
* fetches and blocks any that land on a private/reserved address.
27+
*
28+
* IP literals are classified directly; hostnames are DNS-resolved and every
29+
* returned address is checked. A resolution failure is not treated as a block —
30+
* an unresolvable host reaches nothing, and Chromium fails the load naturally.
31+
* The residual DNS-rebinding TOCTOU window (our lookup vs Chromium's) is only
32+
* fully closable with egress firewalling; {@link isBlockedRequestUrl} adds a
33+
* synchronous per-request literal-IP backstop for redirects and subresources.
34+
*/
35+
export async function checkAgentUrl(rawUrl: string): Promise<UrlGuardResult> {
36+
const url = parseHttpUrl(rawUrl)
37+
if (!url) {
38+
return { ok: false, error: 'URL must be absolute and start with http:// or https://' }
39+
}
40+
41+
const host = unwrapIpv6Brackets(url.hostname)
42+
43+
// IP literal: classify directly, no DNS lookup needed.
44+
if (isIpLiteral(host)) {
45+
if (isPrivateIp(host)) {
46+
logger.warn('Blocked agent navigation to private IP literal', { host })
47+
return BLOCKED
48+
}
49+
return OK
50+
}
51+
52+
try {
53+
const resolved = await dns.lookup(host, { all: true, verbatim: true })
54+
if (resolved.some(({ address }) => isPrivateIp(address))) {
55+
logger.warn('Blocked agent navigation resolving to private IP', { host })
56+
return BLOCKED
57+
}
58+
} catch (error) {
59+
logger.info('Agent navigation host did not resolve; letting the load fail naturally', {
60+
host,
61+
error: getErrorMessage(error),
62+
})
63+
}
64+
65+
return OK
66+
}
67+
68+
/**
69+
* Synchronous backstop for the agent partition's `onBeforeRequest`: blocks any
70+
* request whose host is a **literal** private/reserved IP. This is cheap enough
71+
* to run per-request and catches redirects and subresources that target the
72+
* metadata endpoint or an internal IP directly, without the cost of a DNS
73+
* lookup on every subresource. Hostnames pass here (they are classified at
74+
* navigation time by {@link checkAgentUrl}).
75+
*/
76+
export function isBlockedRequestUrl(rawUrl: string): boolean {
77+
try {
78+
// isPrivateIpHost strips IPv6 brackets itself.
79+
return isPrivateIpHost(new URL(rawUrl).hostname)
80+
} catch {
81+
return false
82+
}
83+
}

apps/desktop/src/main/config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ const logger = createLogger('DesktopConfig')
66

77
export const DEFAULT_ORIGIN = 'https://sim.ai'
88

9-
/** Loopback hostnames that may use plain HTTP (dev + self-host testing). */
10-
export const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
9+
/**
10+
* Loopback hostnames that may use plain HTTP (dev + self-host testing). Matched
11+
* against a parsed `URL.hostname`, which brackets IPv6 authorities — so `[::1]`
12+
* is the form that appears, never a bare `::1`.
13+
*/
14+
export const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]'])
1115

1216
export interface WindowBounds {
1317
x?: number

0 commit comments

Comments
 (0)