Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a99223d
fix(desktop): gate terminal writes and user activation on real OS input
waleedlatif1 Jul 29, 2026
b0fe9d4
fix(desktop): reclaim tmux run temp directories that outlive their wa…
waleedlatif1 Jul 29, 2026
b2cefe2
fix(desktop): contain sign-in handoff failures when no window is avai…
waleedlatif1 Jul 29, 2026
09b40cb
fix(desktop): validate manual-update download urls against the scheme…
waleedlatif1 Jul 29, 2026
73570c7
fix(desktop): scope credential presence grants to the operation proven
waleedlatif1 Jul 29, 2026
9b8df93
fix(desktop): DNS-check agent subresources that are readable or execute
waleedlatif1 Jul 29, 2026
7782a56
fix(desktop): close three credential-disclosure gaps in agent page fu…
waleedlatif1 Jul 29, 2026
fc9469f
fix(desktop): hand the panel occlusion frame only to a user-driven re…
waleedlatif1 Jul 29, 2026
c18f4d3
fix(desktop): act on adversarial review of the security fixes
waleedlatif1 Jul 29, 2026
042db18
refactor(security): one DNS resolver for every SSRF guard, checking a…
waleedlatif1 Jul 29, 2026
a155f93
fix(security): filter refused DNS records instead of failing the host
waleedlatif1 Jul 29, 2026
592c067
fix(desktop): invert the terminal-write gate, and finish the XHTML no…
waleedlatif1 Jul 29, 2026
96346a2
refactor(desktop): simplify what the security fixes added
waleedlatif1 Jul 29, 2026
d0a6eb9
docs(desktop): correct comments that no longer match the code
waleedlatif1 Jul 29, 2026
708dbb3
fix(desktop): stop a command riding inside a fake PTY reply, and fail…
waleedlatif1 Jul 29, 2026
7712e09
fix(desktop): paste from main, and stop a reclaimed run dir printing …
waleedlatif1 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions apps/desktop/src/main/browser-agent/page-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,20 @@ describe('secret-field detection', () => {
],
['new-password field', '<input type="text" autocomplete="new-password" />'],
['uppercase autocomplete token', '<input type="text" autocomplete="Current-Password" />'],
// The spec allows space-separated detail tokens and WebAuthn recommends
// this exact value, so whole-string equality missed it.
[
'WebAuthn multi-token autocomplete',
'<input type="text" autocomplete="current-password webauthn" />',
],
[
'section-scoped autocomplete',
'<input type="text" autocomplete="section-login current-password" />',
],
[
'multi-token new-password with surrounding whitespace',
'<input type="text" autocomplete=" new-password webauthn " />',
],
]

it.each(secretCases)('clickElement refuses a %s', (_label, html) => {
Expand Down Expand Up @@ -276,6 +290,24 @@ describe('collectSnapshot', () => {
expect(outline).not.toContain('value=')
})

it.each([
['a one-time code', 'one-time-code', '123456'],
['a card number', 'cc-number', '4111111111111111'],
['a card security code', 'cc-csc', '737'],
['a card expiry', 'cc-exp', '12/29'],
])('withholds the value of %s while still listing the field', (_label, token, value) => {
document.body.innerHTML = `<input type="text" autocomplete="${token}" value="${value}" aria-label="Field" />`
visible(document.querySelector('input') as HTMLInputElement)

const outline = outlineOf(collectSnapshot())

// Not reported as a password-field: the agent must still be able to fill
// these, it just never learns what is already there.
expect(outline).not.toContain('password-field')
expect(outline).not.toContain(value)
expect(outline).toContain('value-withheld')
})

it('withholds the value of a revealed password field', () => {
document.body.innerHTML =
'<input type="text" autocomplete="current-password" value="hunter2" aria-label="Password" />'
Expand Down Expand Up @@ -317,6 +349,25 @@ describe('readActiveElementState', () => {
expect(readActiveElementState()).toMatchObject({ redacted: true, valuePreview: '' })
})

it.each([
['a one-time code', 'one-time-code', '123456'],
['a card number', 'cc-number', '4111111111111111'],
['a card security code', 'cc-csc', '737'],
])('withholds %s on readback but still confirms the fill', (_label, token, value) => {
document.body.innerHTML = `<input type="text" autocomplete="${token}" value="${value}" />`
setActiveElement(document, document.querySelector('input'))

// valueLength is kept: without it a successful type reads as "still empty"
// and the agent types the code a second time.
expect(readActiveElementState()).toEqual({
activeElement: 'input',
selectedChars: 0,
valueLength: value.length,
valuePreview: '',
redacted: true,
})
})

it('reports ordinary fields in full', () => {
document.body.innerHTML = '<input type="text" value="tokyo" />'
setActiveElement(document, document.querySelector('input'))
Expand All @@ -343,6 +394,35 @@ describe('readActiveElementState', () => {
})
})

describe('XHTML lower-case tagName', () => {
/** An element whose tagName reads lower-case, as it does in an XHTML document. */
function lowerCaseTagInput(html: string): HTMLInputElement {
document.body.innerHTML = html
const input = document.querySelector('input') as HTMLInputElement
Object.defineProperty(input, 'tagName', { configurable: true, get: () => 'input' })
return input
}

it('still refuses a password field whose tagName is lower-case', () => {
const input = lowerCaseTagInput('<input type="password" />')
register(visible(input))

expect(typeIntoElement(0, 'hunter2', false)).toEqual({ error: 'password' })
expect(input.value).toBe('')
})

it('still withholds the value of a lower-case-tagName credential field', () => {
const input = lowerCaseTagInput(
'<input type="password" value="hunter2" aria-label="Password" />'
)
visible(input)

const outline = outlineOf(collectSnapshot())

expect(outline).not.toContain('hunter2')
})
})

describe('activeElementSecrecy', () => {
it('reports safe for an ordinary field', () => {
document.body.innerHTML = '<input type="text" />'
Expand Down Expand Up @@ -386,6 +466,46 @@ describe('activeElementSecrecy', () => {
expect(activeElementSecrecy()).toBe('opaque')
})

it('reports opaque for a password field inside a CLOSED shadow root', () => {
const host = document.createElement('div')
document.body.append(host)
const shadow = host.attachShadow({ mode: 'closed' })
shadow.innerHTML = '<input type="password" />'
// Focus inside a closed root retargets to the host and `shadowRoot` reads
// null, which is exactly what the browser reports and what made this 'safe'.
setActiveElement(document, host)

expect(host.shadowRoot).toBeNull()
expect(activeElementSecrecy()).toBe('opaque')
})

it('reports opaque for a closed shadow root on a custom element', () => {
const host = document.createElement('my-login')
document.body.append(host)
host.attachShadow({ mode: 'closed' }).innerHTML = '<input autocomplete="new-password" />'
setActiveElement(document, host)

expect(activeElementSecrecy()).toBe('opaque')
})

it('still reports safe for a focused element that is focusable in its own right', () => {
// The false-positive guard: a div the page made focusable is focused
// itself, not hiding a shadow tree, so keystrokes are not refused.
document.body.innerHTML = '<div tabindex="0">menu</div>'
setActiveElement(document, document.querySelector('div'))

expect(activeElementSecrecy()).toBe('safe')
})

it('still reports safe for a focused contenteditable', () => {
document.body.innerHTML = '<div contenteditable="true">note</div>'
const editable = document.querySelector('div') as HTMLElement
Object.defineProperty(editable, 'isContentEditable', { get: () => true })
setActiveElement(document, editable)

expect(activeElementSecrecy()).toBe('safe')
})

it('descends into a same-origin frame instead of calling it opaque', () => {
const frame = document.createElement('iframe')
document.body.append(frame)
Expand Down
Loading
Loading