Skip to content

Commit 8ae3d60

Browse files
authored
feat(desktop): SSRF hardening + shared @sim/security/ssrf (re-home of #5763) (#5784)
* feat: re-home @sim/security/ssrf + sim SSRF dedup onto dev (clean core) * feat(desktop): re-integrate SSRF guard + hardening onto rewritten dev Re-applies the browser-agent SSRF guard and hardening onto dev's evolved desktop files (dev rewrote session/driver/handoff/index and split out errors.ts/keyboard.ts): - session.ts: agent-partition onBeforeRequest is the SSRF choke point — DNS-resolving check (fail-closed) for document navigations, synchronous literal-IP backstop for subresources. - driver.ts: browser_navigate/browser_open_tab validate via checkAgentUrl for a clean model error; also adopt shared sleep/getErrorMessage and drop the local reimplementations + banner separators. - index.ts: local-only crashReporter (native minidumps, no upload) + CSP fallback wired into the app session. - window.ts: record the crash-dump dir on renderer_gone. - config.ts: drop the local LOCAL_HOSTNAMES set for the shared isLoopbackHostname (also removes the dead bare '::1'). - cdp.ts: per-WebContents callbacks so a background tab's events reach its own driver. - updater.ts: the manual check now surfaces network/manifest failures instead of silently swallowing them. - README: correct the App Sandbox / security-scoped-bookmark note. - electron-mock: webRequest.onBeforeRequest + crashReporter stubs. - api-validation: annotate dev's validated-envelope double-cast; bump the route-count baseline 964→965 for dev's already-merged route (ratchets stay tight; non-Zod and double-cast at baseline). Skipped as moot (dev already did them independently): launcher isVisible removal, decideStartRoute param drop, local-filesystem clear() removal. * chore(desktop): biome format install-local.ts (pre-existing dev lint failure) * refactor: apply audit cleanup (reuse + simplify) - domain-check: drop the redundant isIpLiteral guard (isLoopbackIp already validates and returns false for non-literals). - session.ts: use shared getErrorMessage instead of the local error ternary (the file already imports it). - tray.ts: use shared sleep() instead of a hand-rolled setTimeout promise. - updater.ts: distinguish the synchronous-throw log from the async-rejection log on the manual update check. * refactor: /simplify pass + review fixes - url-guard: bound the SSRF dns.lookup with a 5s deadline (fails closed on timeout) so a slow/hung resolver can't suspend the check and the onBeforeRequest callback indefinitely (Greptile P2); + test. - Finish the reuse consolidation the earlier pass missed: session.ts second error ternary → getErrorMessage; the bracket-strip idiom → unwrapIpv6Brackets in input-validation.ts, input-validation.server.ts (×2), onepassword/utils.ts (fixes the check:utils banned-pattern CI failure). - driver: document why the tool-level checkAgentUrl coexists with the onBeforeRequest enforcement seam (clean model error; loadURL rejection is swallowed). * fix(desktop): swallow late DNS rejection after the SSRF lookup timeout (Cursor)
1 parent 911c0d7 commit 8ae3d60

34 files changed

Lines changed: 813 additions & 455 deletions

apps/desktop/README.md

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

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

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,7 @@ run('bun', ['run', 'build'])
104104
// framework), which turns local signing into a multi-minute stall. Local
105105
// installs don't need timestamped signatures — only notarized distribution
106106
// builds do.
107-
run('bunx', [
108-
'electron-builder',
109-
'--mac',
110-
'dir',
111-
'--publish',
112-
'never',
113-
'-c.mac.timestamp=none',
114-
])
107+
run('bunx', ['electron-builder', '--mac', 'dir', '--publish', 'never', '-c.mac.timestamp=none'])
115108

116109
const builtApp = RELEASE_DIRS.map((dir) => join(dir, APP_NAME)).find(existsSync)
117110
if (!builtApp) {

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

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

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

@@ -42,7 +44,7 @@ async function send<T = unknown>(
4244

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

4850
if (!contents.debugger.isAttached()) {
@@ -76,6 +78,7 @@ function handleDebuggerEvent(
7678
method: string,
7779
params: Record<string, unknown>
7880
): void {
81+
const callbacks = callbacksByContents.get(contents)
7982
if (method === 'Page.javascriptDialogOpening') {
8083
const type = String(params.type ?? 'dialog')
8184
const message = String(params.message ?? '').slice(0, 500)

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

Lines changed: 21 additions & 33 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 { ToolError } from '@/main/browser-agent/errors'
@@ -41,6 +43,7 @@ import {
4143
typeIntoElement,
4244
} from '@/main/browser-agent/page-functions'
4345
import * as session from '@/main/browser-agent/session'
46+
import { checkAgentUrl } from '@/main/browser-agent/url-guard'
4447

4548
const logger = createLogger('BrowserAgentDriver')
4649

@@ -126,7 +129,7 @@ function instrumentTab(contents: WebContents): void {
126129
.then(() => cdp.setColorScheme(contents, session.getBrowserTheme()))
127130
.catch((error) => {
128131
logger.warn('CDP instrumentation failed', {
129-
error: error instanceof Error ? error.message : String(error),
132+
error: getErrorMessage(error),
130133
})
131134
})
132135
for (const event of [
@@ -160,7 +163,7 @@ export function initDriver(
160163
onTabThemeChanged: (contents, theme) => {
161164
void cdp.setColorScheme(contents, theme).catch((error) => {
162165
logger.warn('Could not update browser tab theme', {
163-
error: error instanceof Error ? error.message : String(error),
166+
error: getErrorMessage(error),
164167
})
165168
})
166169
},
@@ -174,10 +177,6 @@ export function initDriver(
174177
)
175178
}
176179

177-
function sleep(ms: number): Promise<void> {
178-
return new Promise((resolve) => setTimeout(resolve, ms))
179-
}
180-
181180
function str(params: Record<string, unknown>, key: string): string | undefined {
182181
const value = params[key]
183182
return typeof value === 'string' && value.length > 0 ? value : undefined
@@ -205,10 +204,6 @@ function requireNum(params: Record<string, unknown>, key: string): number {
205204
return value
206205
}
207206

208-
// ---------------------------------------------------------------------------
209-
// Page-function execution
210-
// ---------------------------------------------------------------------------
211-
212207
/**
213208
* Serializes a self-contained page function and executes it in the page's
214209
* main world with JSON-encoded arguments (Electron's executeJavaScript has no
@@ -223,7 +218,7 @@ async function execInPage<Args extends unknown[], Result>(
223218
try {
224219
return (await contents.executeJavaScript(expression, true)) as Result
225220
} catch (error) {
226-
const message = error instanceof Error ? error.message : String(error)
221+
const message = getErrorMessage(error)
227222
throw new ToolError(
228223
`Cannot act on this page (${message}). Browser-internal pages cannot be automated — ` +
229224
'navigate to a regular website first.'
@@ -263,10 +258,6 @@ function unwrapPageResult(result: unknown): unknown {
263258
return result
264259
}
265260

266-
// ---------------------------------------------------------------------------
267-
// Navigation
268-
// ---------------------------------------------------------------------------
269-
270261
function waitForLoadComplete(contents: WebContents, timeoutMs: number): Promise<void> {
271262
return new Promise((resolve) => {
272263
let settled = false
@@ -301,10 +292,6 @@ async function activeElementState(contents: WebContents): Promise<Record<string,
301292
return typeof state === 'object' && state !== null ? (state as Record<string, unknown>) : {}
302293
}
303294

304-
// ---------------------------------------------------------------------------
305-
// Takeover
306-
// ---------------------------------------------------------------------------
307-
308295
/**
309296
* Hands control to the user: the page is already natively interactive in the
310297
* panel, and the chat's takeover tool row shows the reason with a Done chip.
@@ -338,19 +325,21 @@ async function runTakeover(): Promise<unknown> {
338325
}
339326
}
340327

341-
// ---------------------------------------------------------------------------
342-
// Tool implementations
343-
// ---------------------------------------------------------------------------
344-
345328
async function executeToolInner(
346329
tool: BrowserToolName,
347330
params: Record<string, unknown>
348331
): Promise<unknown> {
349332
switch (tool) {
350333
case 'browser_navigate': {
351334
const url = requireStr(params, 'url')
352-
if (!/^https?:\/\//i.test(url)) {
353-
throw new ToolError('URL must be absolute and start with http:// or https://')
335+
// Up-front SSRF check for a clean model-facing error. The partition's
336+
// onBeforeRequest is the actual enforcement seam (it also catches
337+
// page-initiated navigations); this pre-check exists because loadURL's
338+
// rejection is swallowed below, so a blocked nav would otherwise surface
339+
// as a blank page with no error.
340+
const guard = await checkAgentUrl(url)
341+
if (!guard.ok) {
342+
throw new ToolError(guard.error ?? 'That address was blocked.')
354343
}
355344
const tab = session.ensureTab()
356345
const contents = tab.view.webContents
@@ -378,12 +367,15 @@ async function executeToolInner(
378367

379368
case 'browser_open_tab': {
380369
const url = str(params, 'url')
370+
if (url) {
371+
const guard = await checkAgentUrl(url)
372+
if (!guard.ok) {
373+
throw new ToolError(guard.error ?? 'That address was blocked.')
374+
}
375+
}
381376
const tab = session.addTab()
382377
const contents = tab.view.webContents
383378
if (url) {
384-
if (!/^https?:\/\//i.test(url)) {
385-
throw new ToolError('URL must be absolute and start with http:// or https://')
386-
}
387379
void contents.loadURL(url).catch(() => {})
388380
const result = await navigationResult(contents)
389381
return { tabId: tab.id, ...result }
@@ -580,10 +572,6 @@ function withNotices(result: unknown): unknown {
580572
return { value: result, notices }
581573
}
582574

583-
// ---------------------------------------------------------------------------
584-
// Public entry points
585-
// ---------------------------------------------------------------------------
586-
587575
/** One real browser can only do one thing at a time — serialize tool calls. */
588576
let toolQueue: Promise<unknown> = Promise.resolve()
589577

@@ -623,7 +611,7 @@ export async function executeTool(
623611
try {
624612
return { ok: true, result: await settled }
625613
} catch (error) {
626-
const message = error instanceof Error ? error.message : String(error)
614+
const message = getErrorMessage(error)
627615
logger.warn('Browser tool failed', { tool, error: message })
628616
return { ok: false, error: message }
629617
}

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getErrorMessage } from '@sim/utils/errors'
1212
import type { BrowserWindow, Input, Session, WebContents } from 'electron'
1313
import { nativeTheme, WebContentsView } from 'electron'
1414
import { registerAgentWebContents } from '@/main/browser-agent/registry'
15+
import { checkAgentUrl, isBlockedRequestUrl } from '@/main/browser-agent/url-guard'
1516

1617
const logger = createLogger('BrowserAgentSession')
1718

@@ -127,6 +128,31 @@ function configureAgentPartition(ses: Session): void {
127128
partitionConfigured = true
128129
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
129130
ses.setPermissionCheckHandler(() => false)
131+
// SSRF choke point for the agent partition. Document navigations (top-level +
132+
// iframes) get the full DNS-resolving check — the one seam every navigation
133+
// passes through, including page-initiated ones the driver never sees (server
134+
// redirects, link clicks, location.href, meta-refresh) — so an internal host
135+
// can't slip in that way. Subresources take the cheap synchronous literal-IP
136+
// backstop instead of a DNS lookup per asset.
137+
ses.webRequest.onBeforeRequest((details, callback) => {
138+
if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') {
139+
void checkAgentUrl(details.url)
140+
.then((guard) => {
141+
if (!guard.ok) {
142+
logger.warn('Blocked agent document navigation to a private host')
143+
}
144+
callback({ cancel: !guard.ok })
145+
})
146+
.catch((error) => {
147+
// Fail closed: an unexpected rejection must cancel, never leave the
148+
// request suspended with no callback.
149+
logger.error('Agent SSRF check failed; cancelling request', { error })
150+
callback({ cancel: true })
151+
})
152+
return
153+
}
154+
callback({ cancel: isBlockedRequestUrl(details.url) })
155+
})
130156
ses.on('will-download', (_event, item) => {
131157
const filename = item.getFilename()
132158
const url = item.getURL()
@@ -172,7 +198,7 @@ function createTabView(): WebContentsView {
172198
void tab.view.webContents.loadURL(details.url).catch(() => {})
173199
} catch (error) {
174200
logger.warn('Could not open browser popup in a new tab', {
175-
error: error instanceof Error ? error.message : String(error),
201+
error: getErrorMessage(error),
176202
})
177203
}
178204
}
@@ -326,7 +352,7 @@ function capturePanelSnapshot(): void {
326352
})
327353
.catch((error) => {
328354
logger.warn('Could not capture browser panel snapshot', {
329-
error: error instanceof Error ? error.message : String(error),
355+
error: getErrorMessage(error),
330356
})
331357
})
332358
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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('fails closed when DNS resolution fails', async () => {
60+
mockLookup.mockRejectedValue(new Error('ENOTFOUND'))
61+
expect((await checkAgentUrl('https://nope.invalid/')).ok).toBe(false)
62+
})
63+
64+
it('fails closed when the DNS lookup exceeds the deadline', async () => {
65+
vi.useFakeTimers()
66+
try {
67+
mockLookup.mockReturnValue(new Promise(() => {})) // never resolves
68+
const pending = checkAgentUrl('https://slow.test/')
69+
await vi.advanceTimersByTimeAsync(5_000)
70+
expect((await pending).ok).toBe(false)
71+
} finally {
72+
vi.useRealTimers()
73+
}
74+
})
75+
})
76+
77+
describe('isBlockedRequestUrl', () => {
78+
it('blocks literal private/reserved hosts', () => {
79+
expect(isBlockedRequestUrl('http://169.254.169.254/latest/meta-data')).toBe(true)
80+
expect(isBlockedRequestUrl('http://127.0.0.1:8080/x')).toBe(true)
81+
expect(isBlockedRequestUrl('https://[fd00::1]/')).toBe(true)
82+
})
83+
84+
it('allows public literals and hostnames (classified at nav time)', () => {
85+
expect(isBlockedRequestUrl('https://8.8.8.8/')).toBe(false)
86+
expect(isBlockedRequestUrl('https://example.com/x')).toBe(false)
87+
})
88+
89+
it('does not throw on malformed input', () => {
90+
expect(isBlockedRequestUrl('::::')).toBe(false)
91+
})
92+
})

0 commit comments

Comments
 (0)