Skip to content

Commit cb69d7a

Browse files
authored
feat(desktop): SSRF hardening for the browser agent + review fixes (#5763)
* 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. * fix(desktop): address review findings (handoff callback gate, fail-closed guards) - handoff: gate onCallback on a non-consuming state match so a format-valid but wrong/expired state no longer fires the callback (which surfaced a spurious "sign-in failed" UI while the genuine callback was still pending). The one-shot listener still survives wrong-state requests (self-DoS guard); consume() re-validates and tears it down on the real callback. - session: the agent-partition onBeforeRequest SSRF check now fails closed — an unexpected rejection cancels the request instead of leaving it suspended. - updater: the manual checkForUpdates() now has a .catch() that logs and shows an error dialog instead of silently swallowing network/manifest failures. * refactor: desktop cleanup, shared loopback helpers, api-validation reconcile Desktop reuse / dead-code (whole-app audit): - driver.ts: use shared `sleep` (@sim/utils/helpers) and `getErrorMessage` (@sim/utils/errors) instead of local reimplementations; drop 7 banner separators. - Remove dead code: LocalFilesystemService.clear(), launcher isVisible(), the unused `url` arg on onDownloadBlocked, and decideStartRoute's always- 'unknown' sessionState param + its dead branch (no launch-time probe by design — see README). - Extract the duplicated launcher load-failure tail into failLaunchLoad(). - handoff.ts: promote a loose comment to TSDoc. Shared loopback helpers (@sim/security/ssrf): - Add isLoopbackIp (full 127/8 + ::1 range) and isLoopbackHostname / LOOPBACK_HOSTNAMES (exact-set), consolidating three duplicated copies: mcp/domain-check.ts (now uses isLoopbackIp + isIpLiteral + unwrapIpv6Brackets, drops its ipaddr import), connectors/s3.ts, and desktop config.ts / navigation.ts (drop the local LOCAL_HOSTNAMES set). urls.ts keeps its own client-side set to avoid pulling ipaddr.js into client bundles. api-validation:strict reconcile (pre-existing dev drift, not from these changes): - Allowlist speech/tts (raw-read streaming route that validates inline) in INDIRECT_ZOD_ROUTES; annotate the launcher's validated-envelope double-cast. Bump the route-count baseline 964→966 for dev's two already-merged speech routes. Non-Zod and double-cast ratchets stay at baseline. * fix(desktop): fail closed on DNS resolution failure in the agent SSRF guard checkAgentUrl now blocks when dns.lookup throws instead of allowing the load. Chromium resolves DNS independently of our Node lookup, so a host our resolver can't resolve could still reach a private address via Chromium's resolver — allowing it was a fail-open gap. Matches validateUrlWithDNS in apps/sim.
1 parent e20d836 commit cb69d7a

43 files changed

Lines changed: 916 additions & 512 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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: 15 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import type {
2222
BrowserToolName,
2323
} from '@sim/browser-protocol'
2424
import { createLogger } from '@sim/logger'
25+
import { getErrorMessage } from '@sim/utils/errors'
26+
import { sleep } from '@sim/utils/helpers'
2527
import type { BrowserWindow, WebContents } from 'electron'
2628
import * as cdp from '@/main/browser-agent/cdp'
2729
import {
@@ -39,6 +41,7 @@ import {
3941
typeIntoElement,
4042
} from '@/main/browser-agent/page-functions'
4143
import * as session from '@/main/browser-agent/session'
44+
import { checkAgentUrl } from '@/main/browser-agent/url-guard'
4245

4346
const logger = createLogger('BrowserAgentDriver')
4447

@@ -118,7 +121,7 @@ function instrumentTab(contents: WebContents): void {
118121
})
119122
.catch((error) => {
120123
logger.warn('CDP instrumentation failed', {
121-
error: error instanceof Error ? error.message : String(error),
124+
error: getErrorMessage(error),
122125
})
123126
})
124127
for (const event of [
@@ -160,10 +163,6 @@ export function setPanelBounds(bounds: BrowserPanelBounds | null): void {
160163
session.setPanelBounds(bounds)
161164
}
162165

163-
function sleep(ms: number): Promise<void> {
164-
return new Promise((resolve) => setTimeout(resolve, ms))
165-
}
166-
167166
function str(params: Record<string, unknown>, key: string): string | undefined {
168167
const value = params[key]
169168
return typeof value === 'string' && value.length > 0 ? value : undefined
@@ -191,10 +190,6 @@ function requireNum(params: Record<string, unknown>, key: string): number {
191190
return value
192191
}
193192

194-
// ---------------------------------------------------------------------------
195-
// Page-function execution
196-
// ---------------------------------------------------------------------------
197-
198193
/**
199194
* Serializes a self-contained page function and executes it in the page's
200195
* main world with JSON-encoded arguments (Electron's executeJavaScript has no
@@ -209,7 +204,7 @@ async function execInPage<Args extends unknown[], Result>(
209204
try {
210205
return (await contents.executeJavaScript(expression, true)) as Result
211206
} catch (error) {
212-
const message = error instanceof Error ? error.message : String(error)
207+
const message = getErrorMessage(error)
213208
throw new ToolError(
214209
`Cannot act on this page (${message}). Browser-internal pages cannot be automated — ` +
215210
'navigate to a regular website first.'
@@ -249,10 +244,6 @@ function unwrapPageResult(result: unknown): unknown {
249244
return result
250245
}
251246

252-
// ---------------------------------------------------------------------------
253-
// Navigation
254-
// ---------------------------------------------------------------------------
255-
256247
function waitForLoadComplete(contents: WebContents, timeoutMs: number): Promise<void> {
257248
return new Promise((resolve) => {
258249
let settled = false
@@ -278,10 +269,6 @@ async function navigationResult(contents: WebContents): Promise<Record<string, u
278269
return { url: contents.getURL(), title: contents.getTitle() }
279270
}
280271

281-
// ---------------------------------------------------------------------------
282-
// Key parsing for browser_press_key
283-
// ---------------------------------------------------------------------------
284-
285272
interface KeyDescriptor {
286273
key: string
287274
code: string
@@ -349,10 +336,6 @@ export function parseKeyCombo(combo: string): ParsedCombo {
349336
throw new ToolError(`Unrecognized key: "${keyPart}"`)
350337
}
351338

352-
// ---------------------------------------------------------------------------
353-
// Trusted key dispatch (CDP Input.dispatchKeyEvent)
354-
// ---------------------------------------------------------------------------
355-
356339
/** CDP `Input` modifier bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. */
357340
function cdpModifiers(combo: ParsedCombo): number {
358341
return (combo.alt ? 1 : 0) | (combo.ctrl ? 2 : 0) | (combo.meta ? 4 : 0) | (combo.shift ? 8 : 0)
@@ -444,10 +427,6 @@ async function activeElementState(contents: WebContents): Promise<Record<string,
444427
return typeof state === 'object' && state !== null ? (state as Record<string, unknown>) : {}
445428
}
446429

447-
// ---------------------------------------------------------------------------
448-
// Takeover
449-
// ---------------------------------------------------------------------------
450-
451430
/**
452431
* Hands control to the user: the page is already natively interactive in the
453432
* panel, and the chat's takeover tool row shows the reason with a Done chip.
@@ -481,19 +460,16 @@ async function runTakeover(): Promise<unknown> {
481460
}
482461
}
483462

484-
// ---------------------------------------------------------------------------
485-
// Tool implementations
486-
// ---------------------------------------------------------------------------
487-
488463
async function executeToolInner(
489464
tool: BrowserToolName,
490465
params: Record<string, unknown>
491466
): Promise<unknown> {
492467
switch (tool) {
493468
case 'browser_navigate': {
494469
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://')
470+
const guard = await checkAgentUrl(url)
471+
if (!guard.ok) {
472+
throw new ToolError(guard.error ?? 'That address was blocked.')
497473
}
498474
const tab = session.ensureTab()
499475
const contents = tab.view.webContents
@@ -521,12 +497,15 @@ async function executeToolInner(
521497

522498
case 'browser_open_tab': {
523499
const url = str(params, 'url')
500+
if (url) {
501+
const guard = await checkAgentUrl(url)
502+
if (!guard.ok) {
503+
throw new ToolError(guard.error ?? 'That address was blocked.')
504+
}
505+
}
524506
const tab = session.addTab()
525507
const contents = tab.view.webContents
526508
if (url) {
527-
if (!/^https?:\/\//i.test(url)) {
528-
throw new ToolError('URL must be absolute and start with http:// or https://')
529-
}
530509
void contents.loadURL(url).catch(() => {})
531510
const result = await navigationResult(contents)
532511
return { tabId: tab.id, ...result }
@@ -723,10 +702,6 @@ function withNotices(result: unknown): unknown {
723702
return { value: result, notices }
724703
}
725704

726-
// ---------------------------------------------------------------------------
727-
// Public entry points
728-
// ---------------------------------------------------------------------------
729-
730705
/** One real browser can only do one thing at a time — serialize tool calls. */
731706
let toolQueue: Promise<unknown> = Promise.resolve()
732707

@@ -756,7 +731,7 @@ export async function executeTool(
756731
try {
757732
return { ok: true, result: await settled }
758733
} catch (error) {
759-
const message = error instanceof Error ? error.message : String(error)
734+
const message = getErrorMessage(error)
760735
logger.warn('Browser tool failed', { tool, error: message })
761736
return { ok: false, error: message }
762737
}

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: 33 additions & 4 deletions
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

@@ -24,7 +25,7 @@ export interface AgentSessionEvents {
2425
/** The active tab changed (new tab, switch, close). */
2526
onActiveTabChanged: (contents: WebContents) => void
2627
/** A download was blocked on the agent partition. */
27-
onDownloadBlocked: (filename: string, url: string) => void
28+
onDownloadBlocked: (filename: string) => void
2829
}
2930

3031
/**
@@ -68,12 +69,38 @@ 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)
83+
.then((guard) => {
84+
if (!guard.ok) {
85+
logger.warn('Blocked agent document navigation to a private host')
86+
}
87+
callback({ cancel: !guard.ok })
88+
})
89+
.catch((error) => {
90+
// Fail closed: an unexpected rejection must cancel, never leave the
91+
// request suspended with no callback.
92+
logger.error('Agent SSRF check failed; cancelling request', { error })
93+
callback({ cancel: true })
94+
})
95+
return
96+
}
97+
callback({ cancel: isBlockedRequestUrl(details.url) })
98+
})
7199
ses.on('will-download', (_event, item) => {
72100
const filename = item.getFilename()
73-
const url = item.getURL()
74101
logger.info('Blocked download in agent browser', { filename })
75102
item.cancel()
76-
events?.onDownloadBlocked(filename, url)
103+
events?.onDownloadBlocked(filename)
77104
})
78105
}
79106

@@ -97,7 +124,9 @@ function createTabView(): WebContentsView {
97124
configureAgentPartition(contents.session)
98125

99126
// Popup-neutralize: window.open and target=_blank navigate the SAME view
100-
// instead of spawning windows — the agent browses one page per tab.
127+
// instead of spawning windows — the agent browses one page per tab. The
128+
// resulting navigation is a document request, so the partition's
129+
// onBeforeRequest SSRF check gates it; no per-call guard is needed here.
101130
contents.setWindowOpenHandler((details) => {
102131
if (/^https?:\/\//i.test(details.url)) {
103132
void contents.loadURL(details.url).catch(() => {})

0 commit comments

Comments
 (0)