Skip to content

feat(desktop): SSRF hardening for the browser agent + review fixes#5763

Merged
waleedlatif1 merged 4 commits into
devfrom
feat/desktop-ssrf-hardening
Jul 19, 2026
Merged

feat(desktop): SSRF hardening for the browser agent + review fixes#5763
waleedlatif1 merged 4 commits into
devfrom
feat/desktop-ssrf-hardening

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

What

Security hardening for the new desktop Electron app, plus the shared-package extraction and review fixes that came out of a deep review of the dev electron branch. Targets dev (the branch the desktop app lives on).

Shared SSRF primitive (@sim/security/ssrf)

Extracts IP classification into one audited, ipaddr.js-based module — isPrivateIp (resolved IP, fail-closed), isPrivateIpHost (host literal, fail-open on DNS names), isIpLiteral, unwrapIpv6Brackets. This de-duplicates three previously-divergent implementations:

  • the fuller isPrivateOrReservedIP in input-validation.server.ts
  • a weaker copy in input-validation.ts (missing the IPv4-compatible-IPv6 embedded check)
  • ad-hoc regex isPrivateIPv4/isPrivateOrLoopbackIPv6 in sap_concur, zoominfo, and the sap contract

All callers (apps/sim outbound-fetch validation, MCP SSRF, DB-host validation, and the desktop shell) now share one primitive. The comprehensive edge-case matrix moved into the package test.

Browser-agent SSRF guard

The embedded agent browser is model/tool-driven, so a navigation to an internal host would let the page be read back via the snapshot/read tools. Fix:

  • The agent partition's onBeforeRequest is the single authoritative choke point: document navigations (top-level + iframes) get a full DNS-resolving check — covering page-initiated navigation the tool layer never sees (server redirects, link clicks, location.href, meta-refresh) — while subresources get a cheap synchronous literal-IP backstop.
  • browser_navigate / browser_open_tab also 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 (uploadToServer:false, no backend); dump dir recorded in the event log.
  • CSP fallback — a minimal, non-drifting policy (frame-ancestors 'self'; object-src 'none'; base-uri 'self') injected only when an app-origin document 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 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.

Testing

  • @sim/security/ssrf: 67 tests
  • apps/desktop: 200 tests
  • affected apps/sim suites (input-validation, domain-check, mock consumers): green
  • type-check + biome clean across apps/desktop, packages/security, and touched apps/sim files

Notes for review

  • Two findings were deliberately not changed (verified against the flow): the permissive idp-in-window navigation default (tightening breaks same-window OAuth connect), and the cross-origin MCP popup (same-origin-only would break the real provider OAuth URL — gated to https instead).

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.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner July 19, 2026 09:24
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 19, 2026 4:36pm

Request Review

@cursor

cursor Bot commented Jul 19, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Touches authentication handoff, outbound/embedded navigation SSRF boundaries, and shared IP classification used across server fetches and desktop—mistakes could block legitimate traffic or leave internal hosts reachable via the agent browser.

Overview
Introduces @sim/security/ssrf (isPrivateIp, isPrivateIpHost, loopback helpers) and moves the full edge-case test matrix there, replacing divergent copies in apps/sim (input validation, MCP, SAP/ZoomInfo/Concur, S3) and ad-hoc regex checks.

For the desktop browser agent, adds url-guard plus partition onBeforeRequest: DNS-resolving checks on main/sub frames (redirects, clicks, iframes) and a synchronous literal-IP backstop on subresources; browser_navigate / browser_open_tab call the guard up front for clearer tool errors. CDP callbacks are keyed per WebContents so background tabs don’t steal events.

Shell hardening: local-only crashReporter, CSP fallback on app-origin documents missing a policy, MCP OAuth window.open limited to https, shared updater configureAutoUpdater, and handoff loopback that only completes on matching CSRF state (wrong-state no longer self-DoS). Launch drops the session probe—decideStartRoute restores last route only; sign-out still handled server-side. Navigation uses shared loopback hostname checks; README clarifies security-scoped bookmarks on Developer ID builds.

Reviewed by Cursor Bugbot for commit a266eba. Configure here.

Comment thread apps/desktop/src/main/handoff.ts Outdated
Comment thread apps/desktop/src/main/browser-agent/session.ts
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR centralises SSRF protection across the monorepo by extracting a shared @sim/security/ssrf package (backed by ipaddr.js) and wires it into the new desktop Electron browser-agent as the single authoritative navigation choke point, while applying several additional hardening fixes (CSP fallback, crash reporter, handoff self-DoS guard, updater refactor, MCP popup HTTPS gate, per-tab CDP callbacks).

  • Shared SSRF primitive: three divergent (and one weaker) private-IP implementations are replaced by one audited isPrivateIp / isPrivateIpHost covering octal/hex IPv4, IPv4-mapped and IPv4-compatible IPv6, NAT64, and every non-unicast range. The accompanying 67-test matrix is thorough.
  • Browser-agent guard: onBeforeRequest on the agent partition intercepts every document navigation (top-level + iframes) with a DNS-resolving checkAgentUrl (fail-closed on resolution failure) and every subresource with a cheap synchronous literal-IP backstop via isBlockedRequestUrl; browser_navigate / browser_open_tab add an upfront tool-layer check so the model receives a clean error.
  • Handoff self-DoS fix: the loopback callback listener now survives format-valid but wrong-state requests (only a matchesPendingState match tears it down), preventing an attacker-controlled redirect from consuming the one-shot listener before the genuine callback arrives.

Confidence Score: 5/5

Safe to merge. All changes are narrowly security-hardening; no regressions were found across the guard logic, handoff flow, CSP injection, or updater paths.

The shared SSRF primitive consolidates three previously divergent implementations and adds IPv4-compatible-IPv6 coverage that was missing from the input-validation.ts copy. The browser-agent guard is architecturally correct — onBeforeRequest is the right interception point, the async DNS path fails closed, and the synchronous subresource backstop is limited to literal IPs. The handoff self-DoS fix, CDP per-tab routing, MCP popup HTTPS gate, and updater refactor are all well-scoped with thorough test coverage.

No files require special attention. The dns.lookup in url-guard.ts has no explicit timeout, but the function correctly fails closed regardless of how the DNS query resolves.

Important Files Changed

Filename Overview
packages/security/src/ssrf.ts New shared SSRF primitive. Correctly handles IPv4-mapped/compatible IPv6, octal/hex IPv4, NAT64, and every non-unicast range via ipaddr.js. Well-tested with 67 test cases.
apps/desktop/src/main/browser-agent/url-guard.ts SSRF guard for agent browser. Async DNS-resolving check for document navigations (fail-closed on resolution failure) and cheap synchronous literal-IP backstop for subresources. Logic is sound; dns.lookup has no explicit timeout (system-dependent).
apps/desktop/src/main/browser-agent/session.ts Wires checkAgentUrl + isBlockedRequestUrl into onBeforeRequest as the single authoritative choke point for the agent partition. Async path now has a catch that fails closed. onDownloadBlocked signature simplification is correct.
apps/desktop/src/main/handoff.ts Self-DoS guard added via matchesPendingState: format-valid but wrong-state callbacks no longer tear down the one-shot listener prematurely. Constant-time comparison preserved.
apps/desktop/src/main/updater.ts configureAutoUpdater() extracted to re-assert channel/allowDowngrade invariants on both launch and manual check paths. Manual check now has a .catch() that logs and surfaces an error dialog.
apps/desktop/src/main/csp.ts Minimal CSP fallback injected only on app-origin main-frame documents that ship no CSP. Correctly uses onHeadersReceived and leaves all other responses untouched.
apps/desktop/src/main/navigation.ts MCP OAuth popup now gated to https: so a renderer-controlled frame name cannot ride an http URL into an in-app window. parseHttpUrl exported for url-guard.ts.
apps/desktop/src/main/browser-agent/cdp.ts CDP callback storage moved from a single module-level variable to a WeakMap keyed by WebContents, fixing background-tab event routing.
apps/sim/lib/core/security/input-validation.server.ts isPrivateOrReservedIP removed and replaced by shared isPrivateIp / isPrivateIpHost. Adds IPv4-compatible IPv6 coverage the old local copy was missing.
apps/sim/lib/mcp/domain-check.ts Migrated to shared @sim/security/ssrf functions. Logic preserved; IPv4-compatible IPv6 now covered.
apps/sim/app/api/tools/sap_concur/shared.ts Ad-hoc regex isPrivateIPv4/isPrivateOrLoopbackIPv6 replaced by isPrivateIpHost. FORBIDDEN_HOSTS retained for defense-in-depth.
apps/desktop/src/main/session-lifecycle.ts decideStartRoute simplified to remove sessionState probe; server-side redirect to /login handles signed-out launches.

Reviews (4): Last reviewed commit: "fix(desktop): fail closed on DNS resolut..." | Re-trigger Greptile

Comment thread apps/desktop/src/main/browser-agent/session.ts
Comment thread apps/desktop/src/main/updater.ts Outdated
…osed 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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 486f918. Configure here.

…concile

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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Pushed a follow-up cleanup wave (whole-desktop audit): shared sleep/getErrorMessage in driver.ts, dead-code removal (LocalFilesystemService.clear, launcher isVisible, decideStartRoute dead param), extracted isLoopbackIp/isLoopbackHostname into @sim/security/ssrf and migrated domain-check/s3/desktop-config off their local copies, plus an api-validation baseline reconcile for dev's already-merged speech routes (sanctioned allowlist + annotation; ratchets kept tight). Also hardened the handoff callback to tear the loopback down on a genuine state match (closes a refresh-race spurious-dialog). Full line-by-line audit + equivalence checks green; 305 tests pass across desktop/security/sim.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/desktop/src/main/browser-agent/url-guard.ts
… 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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit a266eba. Configure here.

@waleedlatif1
waleedlatif1 merged commit cb69d7a into dev Jul 19, 2026
19 checks passed
@waleedlatif1
waleedlatif1 deleted the feat/desktop-ssrf-hardening branch July 19, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant