@@ -17,6 +17,13 @@ const USERNAME_HINTS = ['user', 'login', 'identifier'];
1717const EMAIL_HINTS = [ 'email' , 'mail' ] ;
1818const TOTP_HINTS = [ 'otp' , 'totp' , '2fa' , 'mfa' , 'token' , 'one-time' , 'onetime' , 'verification' , 'auth' , 'security code' ] ;
1919
20+ function isVisibleInput ( input : HTMLInputElement ) : boolean {
21+ if ( input . disabled ) return false ;
22+ const style = window . getComputedStyle ( input ) ;
23+ if ( style . display === 'none' || style . visibility === 'hidden' ) return false ;
24+ return true ;
25+ }
26+
2027function isLikelyTotp ( input : HTMLInputElement ) : boolean {
2128 if ( input . autocomplete === 'one-time-code' ) return true ;
2229
@@ -95,6 +102,48 @@ function scoreForm(fields: DetectedField[]): number {
95102 return score ;
96103}
97104
105+ function findVirtualFormRoot ( input : HTMLInputElement ) : Element {
106+ const MAX_INPUTS = 10 ;
107+ const MIN_INPUTS = 2 ;
108+ const candidateTypes = new Set ( [ 'text' , 'search' , 'email' , 'tel' , 'password' , 'number' ] ) ;
109+
110+ let node : Element | null = input . parentElement ;
111+ for ( let depth = 0 ; depth < 6 && node ; depth ++ ) {
112+ const inputs = Array . from ( node . querySelectorAll ( 'input' ) )
113+ . filter ( ( el ) : el is HTMLInputElement => el instanceof HTMLInputElement )
114+ . filter ( ( el ) => candidateTypes . has ( el . type . toLowerCase ( ) ) )
115+ . filter ( isVisibleInput ) ;
116+
117+ if ( inputs . includes ( input ) && inputs . length >= MIN_INPUTS && inputs . length <= MAX_INPUTS ) {
118+ return node ;
119+ }
120+
121+ node = node . parentElement ;
122+ }
123+
124+ return input . closest ( 'main' ) ?? input . closest ( 'section' ) ?? input . closest ( 'div' ) ?? document . body ;
125+ }
126+
127+ function buildDetectedFormFromInputs ( inputs : HTMLInputElement [ ] , root : Document ) : DetectedForm | null {
128+ const fields : DetectedField [ ] = inputs
129+ . filter ( ( input ) => ! ! input . type )
130+ . map ( ( input ) => ( {
131+ name : input . name || input . id || input . getAttribute ( 'aria-label' ) || 'field' ,
132+ type : classifyField ( input ) ,
133+ selector : selectorFor ( input )
134+ } ) ) ;
135+
136+ if ( ! fields . length ) return null ;
137+ const score = scoreForm ( fields ) ;
138+ if ( score === 0 ) return null ;
139+ return {
140+ action : root . location . href ,
141+ method : 'POST' ,
142+ fields,
143+ score
144+ } ;
145+ }
146+
98147export function scanForms ( root : Document = document ) : DetectedForm [ ] {
99148 const forms = Array . from ( root . forms ) ;
100149 const detected : DetectedForm [ ] = [ ] ;
@@ -120,6 +169,29 @@ export function scanForms(root: Document = document): DetectedForm[] {
120169 } ) ;
121170 }
122171
172+ // Some modern sites don't use <form>. Build "virtual forms" from grouped inputs.
173+ const allInputs = Array . from ( root . querySelectorAll ( 'input' ) )
174+ . filter ( ( el ) : el is HTMLInputElement => el instanceof HTMLInputElement )
175+ . filter ( isVisibleInput ) ;
176+
177+ const seenRoots = new Set < Element > ( ) ;
178+ for ( const input of allInputs ) {
179+ if ( input . form ) continue ;
180+ const kind = classifyField ( input ) ;
181+ if ( kind !== 'password' && kind !== 'totp' ) continue ;
182+
183+ const groupRoot = findVirtualFormRoot ( input ) ;
184+ if ( seenRoots . has ( groupRoot ) ) continue ;
185+ seenRoots . add ( groupRoot ) ;
186+
187+ const groupedInputs = Array . from ( groupRoot . querySelectorAll ( 'input' ) )
188+ . filter ( ( el ) : el is HTMLInputElement => el instanceof HTMLInputElement )
189+ . filter ( isVisibleInput ) ;
190+
191+ const virtual = buildDetectedFormFromInputs ( groupedInputs , root ) ;
192+ if ( virtual ) detected . push ( virtual ) ;
193+ }
194+
123195 return detected ;
124196}
125197
0 commit comments