Skip to content

Commit 96346a2

Browse files
committed
refactor(desktop): simplify what the security fixes added
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.
1 parent 592c067 commit 96346a2

8 files changed

Lines changed: 130 additions & 134 deletions

File tree

apps/desktop/src/main/browser-agent/page-functions.ts

Lines changed: 28 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,7 @@ export function clickElement(id: number): unknown {
303303
const isSecretField = (node: Element | null): boolean => {
304304
if (!node || node.tagName !== 'INPUT') return false
305305
if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
306-
// Space-separated detail tokens are spec-legal and WebAuthn recommends
307-
// `current-password webauthn`, so whole-string equality missed real values
308-
// on exactly the type=text credential fields where autocomplete is the
309-
// only signal there is.
306+
// Token membership, not equality — see the module header.
310307
const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
311308
return hint
312309
.split(/\s+/)
@@ -359,10 +356,7 @@ export function focusElementForTyping(id: number): unknown {
359356
const isSecretField = (node: Element | null): boolean => {
360357
if (!node || node.tagName !== 'INPUT') return false
361358
if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
362-
// Space-separated detail tokens are spec-legal and WebAuthn recommends
363-
// `current-password webauthn`, so whole-string equality missed real values
364-
// on exactly the type=text credential fields where autocomplete is the
365-
// only signal there is.
359+
// Token membership, not equality — see the module header.
366360
const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
367361
return hint
368362
.split(/\s+/)
@@ -416,25 +410,14 @@ export function readActiveElementState(): unknown {
416410
const isSecretField = (node: Element | null): boolean => {
417411
if (!node || node.tagName !== 'INPUT') return false
418412
if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
419-
// Space-separated detail tokens are spec-legal and WebAuthn recommends
420-
// `current-password webauthn`, so whole-string equality missed real values
421-
// on exactly the type=text credential fields where autocomplete is the
422-
// only signal there is.
413+
// Token membership, not equality — see the module header.
423414
const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
424415
return hint
425416
.split(/\s+/)
426417
.some((token) => token === 'current-password' || token === 'new-password')
427418
}
428419

429-
/**
430-
* Fields whose value is as sensitive as a password but which the agent must
431-
* still be able to FILL: one-time codes and payment details.
432-
*
433-
* Deliberately separate from isSecretField. That one also gates keystrokes
434-
* (activeElementSecrecy feeds the driver's press-key guard), so folding these
435-
* tokens into it would stop the agent completing a checkout or an OTP prompt —
436-
* work it is legitimately asked to do. Only the value is withheld here.
437-
*/
420+
/** Sensitive-but-fillable fields; see collectSnapshot's copy for why. */
438421
const isSensitiveValueField = (node: Element | null): boolean => {
439422
if (!node || node.tagName !== 'INPUT') return false
440423
const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
@@ -548,10 +531,7 @@ export function activeElementSecrecy(): string {
548531
const isSecretField = (node: Element | null): boolean => {
549532
if (!node || node.tagName !== 'INPUT') return false
550533
if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
551-
// Space-separated detail tokens are spec-legal and WebAuthn recommends
552-
// `current-password webauthn`, so whole-string equality missed real values
553-
// on exactly the type=text credential fields where autocomplete is the
554-
// only signal there is.
534+
// Token membership, not equality — see the module header.
555535
const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
556536
return hint
557537
.split(/\s+/)
@@ -594,24 +574,31 @@ export function activeElementSecrecy(): string {
594574
// detector is `attachShadow` throwing, which is destructive. Narrowing this
595575
// needs the driver to stop trusting a page-derived signal, not a better
596576
// guess here.
577+
// Declared inside the function: this is injected as source, so it cannot
578+
// reference module scope, but a local is fine and keeps the list diffable.
579+
// IFRAME/FRAME are here so the frame branch below, not this early return,
580+
// classifies them.
581+
const FOCUSABLE_TAGS = [
582+
'INPUT',
583+
'TEXTAREA',
584+
'SELECT',
585+
'BUTTON',
586+
'A',
587+
'AREA',
588+
'SUMMARY',
589+
'DIALOG',
590+
'VIDEO',
591+
'AUDIO',
592+
'EMBED',
593+
'OBJECT',
594+
'IFRAME',
595+
'FRAME',
596+
]
597597
const focusableItself =
598598
active === active.ownerDocument.body ||
599599
active.isContentEditable ||
600600
active.hasAttribute('tabindex') ||
601-
tag === 'INPUT' ||
602-
tag === 'TEXTAREA' ||
603-
tag === 'SELECT' ||
604-
tag === 'BUTTON' ||
605-
tag === 'A' ||
606-
tag === 'AREA' ||
607-
tag === 'SUMMARY' ||
608-
tag === 'DIALOG' ||
609-
tag === 'VIDEO' ||
610-
tag === 'AUDIO' ||
611-
tag === 'EMBED' ||
612-
tag === 'OBJECT' ||
613-
tag === 'IFRAME' ||
614-
tag === 'FRAME'
601+
FOCUSABLE_TAGS.indexOf(tag) !== -1
615602
if (!shadow && !focusableItself) return 'opaque'
616603
if (tag === 'IFRAME' || tag === 'FRAME') {
617604
let inner: Document | null = null
@@ -636,10 +623,7 @@ export function typeIntoElement(id: number, text: string, submit: boolean): unkn
636623
const isSecretField = (node: Element | null): boolean => {
637624
if (!node || node.tagName !== 'INPUT') return false
638625
if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
639-
// Space-separated detail tokens are spec-legal and WebAuthn recommends
640-
// `current-password webauthn`, so whole-string equality missed real values
641-
// on exactly the type=text credential fields where autocomplete is the
642-
// only signal there is.
626+
// Token membership, not equality — see the module header.
643627
const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
644628
return hint
645629
.split(/\s+/)
@@ -712,10 +696,7 @@ export function pressKeyOnPage(
712696
const isSecretField = (node: Element | null): boolean => {
713697
if (!node || node.tagName !== 'INPUT') return false
714698
if (String((node as HTMLInputElement).type || '').toLowerCase() === 'password') return true
715-
// Space-separated detail tokens are spec-legal and WebAuthn recommends
716-
// `current-password webauthn`, so whole-string equality missed real values
717-
// on exactly the type=text credential fields where autocomplete is the
718-
// only signal there is.
699+
// Token membership, not equality — see the module header.
719700
const hint = String(node.getAttribute('autocomplete') || '').toLowerCase()
720701
return hint
721702
.split(/\s+/)

apps/desktop/src/main/browser-agent/url-guard.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ export interface UrlGuardResult {
1919

2020
const OK: UrlGuardResult = { ok: true }
2121

22+
/**
23+
* A URL's host in the one form every guard here compares against.
24+
*
25+
* IPv6 brackets are unwrapped so the address classifiers see a bare address,
26+
* and a trailing dot is dropped — it is a legal absolute name that resolves the
27+
* same, so leaving it on would let `intranet.` and `intranet` be judged and
28+
* cached as two different hosts. Null when the URL does not parse or carries no
29+
* host, which every caller treats as nothing to block.
30+
*/
31+
function guardHost(rawUrl: string): string | null {
32+
let hostname: string
33+
try {
34+
hostname = new URL(rawUrl).hostname
35+
} catch {
36+
return null
37+
}
38+
return unwrapIpv6Brackets(hostname).replace(/\.$/, '') || null
39+
}
40+
2241
/**
2342
* Whether an address is off limits to the embedded browser.
2443
*
@@ -66,7 +85,10 @@ export async function checkAgentUrl(rawUrl: string): Promise<UrlGuardResult> {
6685
return { ok: false, error: 'URL must be absolute and start with http:// or https://' }
6786
}
6887

69-
const host = unwrapIpv6Brackets(url.hostname)
88+
const host = guardHost(url.href)
89+
if (!host) {
90+
return { ok: false, error: 'That address has no host to check.' }
91+
}
7092

7193
// IP literal: classify directly, no DNS lookup needed.
7294
if (isIpLiteral(host)) {
@@ -126,7 +148,7 @@ const HOST_VERDICT_TTL_MS = 30_000
126148

127149
/**
128150
* Ceiling on the cache. A hostile page can name unlimited hostnames, so this is
129-
* bounded rather than left to grow; the oldest entry is evicted first.
151+
* bounded rather than left to grow.
130152
*/
131153
const MAX_HOST_VERDICTS = 256
132154

@@ -187,15 +209,7 @@ export function clearHostVerdictCache(): void {
187209
* stick.
188210
*/
189211
export async function isBlockedSubresourceUrl(rawUrl: string): Promise<boolean> {
190-
let hostname: string
191-
try {
192-
hostname = new URL(rawUrl).hostname
193-
} catch {
194-
return false
195-
}
196-
// A trailing dot is a legal absolute name that resolves the same, so it is
197-
// stripped for the key rather than caching the same host twice.
198-
const host = unwrapIpv6Brackets(hostname).replace(/\.$/, '')
212+
const host = guardHost(rawUrl)
199213
if (!host) return false
200214
if (isIpLiteral(host)) return isBlockedAddress(host)
201215

@@ -236,12 +250,7 @@ export async function isBlockedSubresourceUrl(rawUrl: string): Promise<boolean>
236250
* requests still relying on this alone.
237251
*/
238252
export function isBlockedRequestUrl(rawUrl: string): boolean {
239-
try {
240-
// isPrivateIpHost strips IPv6 brackets itself; unwrap again for the
241-
// loopback carve-out, which takes a bare address.
242-
const host = new URL(rawUrl).hostname
243-
return isPrivateIpHost(host) && !isLoopbackIp(unwrapIpv6Brackets(host))
244-
} catch {
245-
return false
246-
}
253+
const host = guardHost(rawUrl)
254+
if (!host) return false
255+
return isPrivateIpHost(host) && !isLoopbackIp(host)
247256
}

apps/desktop/src/main/browser-credentials/os-auth.ts

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,18 @@ const AUTH_GRACE_MS = 30_000
2929
* which Universal Clipboard syncs to the user's other devices. Ordering them
3030
* either way lets one consent authorize an exposure the prompt never described.
3131
*/
32-
export const SECRET_OPERATIONS = ['reveal', 'copy'] as const
33-
34-
type SecretOperation = (typeof SECRET_OPERATIONS)[number]
32+
export type SecretOperation = 'reveal' | 'copy'
3533

3634
/**
3735
* Proof of presence per credential AND operation.
3836
*
39-
* Keyed on both rather than holding one scalar per credential: a single slot
40-
* would be overwritten on each grant, so reveal → copy → reveal prompts three
41-
* times inside one window even though each was already proven. Re-prompting for
42-
* something the user just authorized is what teaches people to approve without
43-
* reading.
37+
* Nested rather than one scalar per credential: a single slot would be
38+
* overwritten on each grant, so reveal → copy → reveal prompts three times
39+
* inside one window even though each was already proven. Nesting also keeps
40+
* revoke-by-credential a single delete, so a third operation added later cannot
41+
* be left behind by a revoke that forgot to enumerate it.
4442
*/
45-
const provenUntil = new Map<string, number>()
46-
47-
function grantKey(credentialId: string, operation: SecretOperation): string {
48-
return `${credentialId}\u0000${operation}`
49-
}
43+
const provenUntil = new Map<string, Map<SecretOperation, number>>()
5044

5145
export interface SecretAuthRequest {
5246
/**
@@ -70,11 +64,15 @@ export interface SecretAuthRequest {
7064
}
7165

7266
function hasFreshProof(credentialId: string, operation: SecretOperation): boolean {
73-
const key = grantKey(credentialId, operation)
74-
const expiry = provenUntil.get(key)
67+
const grants = provenUntil.get(credentialId)
68+
const expiry = grants?.get(operation)
7569
if (expiry === undefined) return false
76-
if (Date.now() >= expiry) {
77-
provenUntil.delete(key)
70+
// Also lapsed when the remaining time exceeds the whole window, which is what
71+
// a backwards clock step looks like — otherwise a corrected clock would leave
72+
// a grant standing far longer than it was granted for.
73+
const remaining = expiry - Date.now()
74+
if (remaining <= 0 || remaining > AUTH_GRACE_MS) {
75+
grants?.delete(operation)
7876
return false
7977
}
8078
return true
@@ -87,14 +85,8 @@ function hasFreshProof(credentialId: string, operation: SecretOperation): boolea
8785
* Called with no id, it revokes everything.
8886
*/
8987
export function revokeSecretAuthorization(credentialId?: string): void {
90-
if (credentialId === undefined) {
91-
provenUntil.clear()
92-
return
93-
}
94-
// Every operation's grant for this credential, since the key carries both.
95-
for (const operation of SECRET_OPERATIONS) {
96-
provenUntil.delete(grantKey(credentialId, operation))
97-
}
88+
if (credentialId === undefined) provenUntil.clear()
89+
else provenUntil.delete(credentialId)
9890
}
9991

10092
/**
@@ -124,7 +116,9 @@ export async function authorizeForSecret({
124116
}: SecretAuthRequest): Promise<boolean> {
125117
if (hasFreshProof(credentialId, operation)) return true
126118
if (!(await promptForSecret(reason, action))) return false
127-
provenUntil.set(grantKey(credentialId, operation), Date.now() + AUTH_GRACE_MS)
119+
const grants = provenUntil.get(credentialId) ?? new Map<SecretOperation, number>()
120+
grants.set(operation, Date.now() + AUTH_GRACE_MS)
121+
provenUntil.set(credentialId, grants)
128122
return true
129123
}
130124

apps/desktop/src/main/ipc.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -316,14 +316,8 @@ function localFilesystemRequestNeedsToolAuthorization(request: unknown): boolean
316316
}
317317

318318
/**
319-
* Whether the caller has a real user gesture behind it.
320-
*
321-
* Answered from the main process's own record of OS input, never by asking the
322-
* renderer. The previous implementation ran
323-
* `navigator.userActivation?.isActive === true` through
324-
* `frame.executeJavaScript`, which evaluates in the page's main world — the
325-
* same world as the compromised page this gate exists to stop, which need only
326-
* redefine `navigator.userActivation` to make the check always pass.
319+
* Whether the caller has a real user gesture behind it, answered from the main
320+
* process's own record of OS input rather than by asking the renderer.
327321
*/
328322
function senderHasUserGesture(event: IpcMainEvent | IpcMainInvokeEvent): boolean {
329323
return hasRecentDiscreteInput(event.sender)
@@ -337,8 +331,17 @@ function senderHasUserGesture(event: IpcMainEvent | IpcMainInvokeEvent): boolean
337331
* mouse reports, and DCS/OSC responses. All machine-generated and
338332
* self-delimiting, which is what makes them safe to enumerate.
339333
*/
340-
const PTY_REPLY =
341-
/^(?:\u001b\[[0-9;?]*[Rc]|\u001b\[[IO]|\u001b\[M[\s\S]{3}|\u001b\[<[0-9;]*[mM]|\u001bP[\s\S]*?\u001b\\|\u001b\][\s\S]*?\u0007)+$/
334+
const PTY_REPLY_PATTERNS = [
335+
/\u001b\[[0-9;?]*[Rc]/, // DSR cursor position, device attributes
336+
/\u001b\[[IO]/, // focus in/out (mode 1004)
337+
/\u001b\[M[\s\S]{3}/, // X10 mouse report
338+
/\u001b\[<[0-9;]*[mM]/, // SGR mouse report
339+
/\u001bP[\s\S]*?\u001b\\/, // DCS response
340+
/\u001b\][\s\S]*?\u0007/, // OSC response
341+
]
342+
const PTY_REPLY = new RegExp(
343+
`^(?:${PTY_REPLY_PATTERNS.map((pattern) => pattern.source).join('|')})+$`
344+
)
342345

343346
/**
344347
* Whether a terminal-write payload needs a person behind it.

apps/desktop/src/main/terminal/index.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -315,11 +315,11 @@ export class TerminalService {
315315
private reapFinishedRuns(terminalId: string): void {
316316
const pending = this.pendingRuns.get(terminalId)
317317
if (!pending) return
318-
const stillRunning = pending.filter((handle) => {
319-
if (!isRunComplete(handle)) return true
320-
handle.dispose()
321-
return false
322-
})
318+
const stillRunning: TmuxRunHandle[] = []
319+
for (const handle of pending) {
320+
if (isRunComplete(handle)) handle.dispose()
321+
else stillRunning.push(handle)
322+
}
323323
if (stillRunning.length === 0) this.pendingRuns.delete(terminalId)
324324
else this.pendingRuns.set(terminalId, stillRunning)
325325
}
@@ -501,7 +501,10 @@ export class TerminalService {
501501
}
502502
this.sessions.clear()
503503
this.tmuxCache.clear()
504-
for (const terminalId of [...this.pendingRuns.keys()]) this.releasePendingRuns(terminalId)
504+
for (const handles of this.pendingRuns.values()) {
505+
for (const handle of handles) handle.dispose()
506+
}
507+
this.pendingRuns.clear()
505508
this.activeId = null
506509
// A stale claim here is what let Cmd-W close a shell that no longer exists.
507510
this.setPanelFocused(false)

0 commit comments

Comments
 (0)