@@ -140,6 +140,20 @@ describe('secret-field detection', () => {
140140 ] ,
141141 [ 'new-password field' , '<input type="text" autocomplete="new-password" />' ] ,
142142 [ 'uppercase autocomplete token' , '<input type="text" autocomplete="Current-Password" />' ] ,
143+ // The spec allows space-separated detail tokens and WebAuthn recommends
144+ // this exact value, so whole-string equality missed it.
145+ [
146+ 'WebAuthn multi-token autocomplete' ,
147+ '<input type="text" autocomplete="current-password webauthn" />' ,
148+ ] ,
149+ [
150+ 'section-scoped autocomplete' ,
151+ '<input type="text" autocomplete="section-login current-password" />' ,
152+ ] ,
153+ [
154+ 'multi-token new-password with surrounding whitespace' ,
155+ '<input type="text" autocomplete=" new-password webauthn " />' ,
156+ ] ,
143157 ]
144158
145159 it . each ( secretCases ) ( 'clickElement refuses a %s' , ( _label , html ) => {
@@ -276,6 +290,24 @@ describe('collectSnapshot', () => {
276290 expect ( outline ) . not . toContain ( 'value=' )
277291 } )
278292
293+ it . each ( [
294+ [ 'a one-time code' , 'one-time-code' , '123456' ] ,
295+ [ 'a card number' , 'cc-number' , '4111111111111111' ] ,
296+ [ 'a card security code' , 'cc-csc' , '737' ] ,
297+ [ 'a card expiry' , 'cc-exp' , '12/29' ] ,
298+ ] ) ( 'withholds the value of %s while still listing the field' , ( _label , token , value ) => {
299+ document . body . innerHTML = `<input type="text" autocomplete="${ token } " value="${ value } " aria-label="Field" />`
300+ visible ( document . querySelector ( 'input' ) as HTMLInputElement )
301+
302+ const outline = outlineOf ( collectSnapshot ( ) )
303+
304+ // Not reported as a password-field: the agent must still be able to fill
305+ // these, it just never learns what is already there.
306+ expect ( outline ) . not . toContain ( 'password-field' )
307+ expect ( outline ) . not . toContain ( value )
308+ expect ( outline ) . toContain ( 'value-withheld' )
309+ } )
310+
279311 it ( 'withholds the value of a revealed password field' , ( ) => {
280312 document . body . innerHTML =
281313 '<input type="text" autocomplete="current-password" value="hunter2" aria-label="Password" />'
@@ -317,6 +349,25 @@ describe('readActiveElementState', () => {
317349 expect ( readActiveElementState ( ) ) . toMatchObject ( { redacted : true , valuePreview : '' } )
318350 } )
319351
352+ it . each ( [
353+ [ 'a one-time code' , 'one-time-code' , '123456' ] ,
354+ [ 'a card number' , 'cc-number' , '4111111111111111' ] ,
355+ [ 'a card security code' , 'cc-csc' , '737' ] ,
356+ ] ) ( 'withholds %s on readback but still confirms the fill' , ( _label , token , value ) => {
357+ document . body . innerHTML = `<input type="text" autocomplete="${ token } " value="${ value } " />`
358+ setActiveElement ( document , document . querySelector ( 'input' ) )
359+
360+ // valueLength is kept: without it a successful type reads as "still empty"
361+ // and the agent types the code a second time.
362+ expect ( readActiveElementState ( ) ) . toEqual ( {
363+ activeElement : 'input' ,
364+ selectedChars : 0 ,
365+ valueLength : value . length ,
366+ valuePreview : '' ,
367+ redacted : true ,
368+ } )
369+ } )
370+
320371 it ( 'reports ordinary fields in full' , ( ) => {
321372 document . body . innerHTML = '<input type="text" value="tokyo" />'
322373 setActiveElement ( document , document . querySelector ( 'input' ) )
@@ -343,6 +394,35 @@ describe('readActiveElementState', () => {
343394 } )
344395} )
345396
397+ describe ( 'XHTML lower-case tagName' , ( ) => {
398+ /** An element whose tagName reads lower-case, as it does in an XHTML document. */
399+ function lowerCaseTagInput ( html : string ) : HTMLInputElement {
400+ document . body . innerHTML = html
401+ const input = document . querySelector ( 'input' ) as HTMLInputElement
402+ Object . defineProperty ( input , 'tagName' , { configurable : true , get : ( ) => 'input' } )
403+ return input
404+ }
405+
406+ it ( 'still refuses a password field whose tagName is lower-case' , ( ) => {
407+ const input = lowerCaseTagInput ( '<input type="password" />' )
408+ register ( visible ( input ) )
409+
410+ expect ( typeIntoElement ( 0 , 'hunter2' , false ) ) . toEqual ( { error : 'password' } )
411+ expect ( input . value ) . toBe ( '' )
412+ } )
413+
414+ it ( 'still withholds the value of a lower-case-tagName credential field' , ( ) => {
415+ const input = lowerCaseTagInput (
416+ '<input type="password" value="hunter2" aria-label="Password" />'
417+ )
418+ visible ( input )
419+
420+ const outline = outlineOf ( collectSnapshot ( ) )
421+
422+ expect ( outline ) . not . toContain ( 'hunter2' )
423+ } )
424+ } )
425+
346426describe ( 'activeElementSecrecy' , ( ) => {
347427 it ( 'reports safe for an ordinary field' , ( ) => {
348428 document . body . innerHTML = '<input type="text" />'
@@ -386,6 +466,46 @@ describe('activeElementSecrecy', () => {
386466 expect ( activeElementSecrecy ( ) ) . toBe ( 'opaque' )
387467 } )
388468
469+ it ( 'reports opaque for a password field inside a CLOSED shadow root' , ( ) => {
470+ const host = document . createElement ( 'div' )
471+ document . body . append ( host )
472+ const shadow = host . attachShadow ( { mode : 'closed' } )
473+ shadow . innerHTML = '<input type="password" />'
474+ // Focus inside a closed root retargets to the host and `shadowRoot` reads
475+ // null, which is exactly what the browser reports and what made this 'safe'.
476+ setActiveElement ( document , host )
477+
478+ expect ( host . shadowRoot ) . toBeNull ( )
479+ expect ( activeElementSecrecy ( ) ) . toBe ( 'opaque' )
480+ } )
481+
482+ it ( 'reports opaque for a closed shadow root on a custom element' , ( ) => {
483+ const host = document . createElement ( 'my-login' )
484+ document . body . append ( host )
485+ host . attachShadow ( { mode : 'closed' } ) . innerHTML = '<input autocomplete="new-password" />'
486+ setActiveElement ( document , host )
487+
488+ expect ( activeElementSecrecy ( ) ) . toBe ( 'opaque' )
489+ } )
490+
491+ it ( 'still reports safe for a focused element that is focusable in its own right' , ( ) => {
492+ // The false-positive guard: a div the page made focusable is focused
493+ // itself, not hiding a shadow tree, so keystrokes are not refused.
494+ document . body . innerHTML = '<div tabindex="0">menu</div>'
495+ setActiveElement ( document , document . querySelector ( 'div' ) )
496+
497+ expect ( activeElementSecrecy ( ) ) . toBe ( 'safe' )
498+ } )
499+
500+ it ( 'still reports safe for a focused contenteditable' , ( ) => {
501+ document . body . innerHTML = '<div contenteditable="true">note</div>'
502+ const editable = document . querySelector ( 'div' ) as HTMLElement
503+ Object . defineProperty ( editable , 'isContentEditable' , { get : ( ) => true } )
504+ setActiveElement ( document , editable )
505+
506+ expect ( activeElementSecrecy ( ) ) . toBe ( 'safe' )
507+ } )
508+
389509 it ( 'descends into a same-origin frame instead of calling it opaque' , ( ) => {
390510 const frame = document . createElement ( 'iframe' )
391511 document . body . append ( frame )
0 commit comments