diff --git a/apps/browser/src/autofill/models/autofill-field.ts b/apps/browser/src/autofill/models/autofill-field.ts index 1a8c3bb875b..4d89a3dc4a5 100644 --- a/apps/browser/src/autofill/models/autofill-field.ts +++ b/apps/browser/src/autofill/models/autofill-field.ts @@ -7,6 +7,12 @@ import { InlineMenuFillType, } from "../enums/autofill-overlay.enum"; +export type DomainMatch = { + domain: string; + fieldType: AutofillFieldQualifierType; + xpathResult: XPathResult; +}; + /** * Represents a single field that is collected from the page source and is potentially autofilled. */ @@ -126,6 +132,8 @@ export default class AutofillField { accountCreationFieldType?: InlineMenuAccountCreationFieldTypes; + domainMatch?: DomainMatch; + /** * used for totp multiline calculations */ diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 4db00901759..f518d55d53c 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -199,18 +199,30 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ autofillFieldData: AutofillField, pageDetails: AutofillPageDetails, ) { + console.log("SETUP OVERLAY LISTENERS"); + + console.log("currentlyInSandboxedIframe()", currentlyInSandboxedIframe()); + console.log( + "this.formFieldElements.has(formFieldElement)", + this.formFieldElements.has(formFieldElement), + ); + console.log( + "this.isIgnoredField(autofillFieldData, pageDetails)", + this.isIgnoredField(autofillFieldData, pageDetails), + ); + if ( currentlyInSandboxedIframe() || this.formFieldElements.has(formFieldElement) || - this.isIgnoredField(autofillFieldData, pageDetails) + this.isIgnoredField(autofillFieldData, pageDetails).result ) { return; } - + console.log("IS IT HIDDEN"); if (this.isHiddenField(formFieldElement, autofillFieldData)) { return; } - + console.log("SETUP ON QUALIFIED FIELD"); await this.setupOverlayListenersOnQualifiedField(formFieldElement, autofillFieldData); } @@ -1048,16 +1060,26 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ private isIgnoredField( autofillFieldData: AutofillField, pageDetails: AutofillPageDetails, - ): boolean { - if (this.ignoredFieldTypes.has(autofillFieldData.type)) { - return true; + ): { result: boolean; message: string } { + const message = "isIgnoredField"; + + console.log(message, { autofillFieldData }); + + const ignoredTypeResult = Array.from(this.ignoredFieldTypes).find( + (v) => v === autofillFieldData.type, + ); + if (ignoredTypeResult) { + return { + result: true, + message: `${message} // field type is ignored type: ${ignoredTypeResult}`, + }; } if ( this.inlineMenuFieldQualificationService.isFieldForLoginForm(autofillFieldData, pageDetails) ) { void this.setQualifiedLoginFillType(autofillFieldData); - return false; + return { result: false, message: `${message} // field is for login form` }; } if ( @@ -1068,7 +1090,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ ) ) { autofillFieldData.inlineMenuFillType = CipherType.Card; - return false; + return { + result: false, + message: `${message} // field is for credit card form & inline menu cards shown`, + }; } if ( @@ -1078,7 +1103,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ ) ) { this.setQualifiedAccountCreationFillType(autofillFieldData); - return false; + return { result: false, message: `${message} // field is for account creation form` }; } if ( @@ -1089,10 +1114,16 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ ) ) { autofillFieldData.inlineMenuFillType = CipherType.Identity; - return false; + return { + result: false, + message: `${message} // field is for identity form & inline menu identities shown`, + }; } - return true; + return { + result: true, + message: `${message} // field ignored by default — not in any unignorable condition`, + }; } /** @@ -1101,6 +1132,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param autofillFieldData - Autofill field data captured from the form field element. */ private async setQualifiedLoginFillType(autofillFieldData: AutofillField) { + console.log("setQualifiedLoginFillType"); autofillFieldData.inlineMenuFillType = CipherType.Login; autofillFieldData.showPasskeys = autofillFieldData.autoCompleteType.includes("webauthn"); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index c6af9739773..44b24b9fcb8 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,6 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import AutofillField from "../models/autofill-field"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { AutofillFieldQualifier } from "../enums/autofill-field.enums"; +import AutofillField, { DomainMatch } from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; @@ -34,6 +36,7 @@ import { } from "./abstractions/collect-autofill-content.service"; import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service"; import { DomQueryService } from "./abstractions/dom-query.service"; +import { P } from "@angular/cdk/portal-directives.d-BoG39gYN"; export class CollectAutofillContentService implements CollectAutofillContentServiceInterface { private readonly sendExtensionMessage = sendExtensionMessage; @@ -168,6 +171,13 @@ export class CollectAutofillContentService implements CollectAutofillContentServ [...this.autofillFieldElements].sort((a, b) => a[1].elementNumber - b[1].elementNumber), ); } + /** + * + * @returns string (href) + */ + private getSafeDocumentUrl() { + return (document.defaultView || globalThis).location.href; + } /** * Formats and returns the AutofillPageDetails object @@ -181,7 +191,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ ): AutofillPageDetails { return { title: document.title, - url: (document.defaultView || globalThis).location.href, + url: this.getSafeDocumentUrl(), documentUrl: document.location.href, forms: autofillFormsData, fields: autofillFieldsData, @@ -398,7 +408,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } const fieldFormElement = (element as ElementWithOpId).form; - const autofillField = { + const autofillField: AutofillField = { ...autofillFieldBase, ...autofillFieldLabels, rel: this.getPropertyOrAttribute(element, "rel"), @@ -416,12 +426,101 @@ export class CollectAutofillContentService implements CollectAutofillContentServ "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), "aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true), "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), + domainMatch: this.matchesXPathForDomain(element) || null, }; this.cacheAutofillFieldElement(index, element, autofillField); return autofillField; }; + matchesXPathForDomain(element: ElementWithOpId): DomainMatch { + const url = this.getSafeDocumentUrl(); + const domain = Utils.getDomain(url); + const matchers = [ + { + domain: "cnn.com", + xpathQualifiers: [ + { + qualifierType: AutofillFieldQualifier.identityEmail, + xpath: '//*[@id="login-email-input"]', + fullxpath: + "/html/body/div[1]/section[2]/section/section/section/div[2]/div[2]/div/form/div[1]/div/div[1]/input", + }, + { + qualifierType: AutofillFieldQualifier.password, + xpath: '//*[@id="login-password-input"]', + fullxpath: + "/html/body/div[1]/section[2]/section/section/section/div[2]/div[2]/div/form/div[2]/div/div[1]/input", + }, + ], + }, + { + domain: "bestbuy.com", + xpathQualifiers: [ + { + qualifierType: AutofillFieldQualifier.identityEmail, + fullxpath: + "/html/body/div[1]/div/section/main/div[2]/div/div/div[1]/div/div/div/div/div/form/div[1]/div/input", + }, + { + qualifierType: AutofillFieldQualifier.password, + fullxpath: + "/html/body/div[1]/div/section/main/div[2]/div/div/div[1]/div/div/div/div/div/form/fieldset/fieldset/div[5]/div[2]/div/div/input", + }, + ], + }, + { + domain: "samsclub.com", + xpathQualifiers: [ + { + qualifierType: AutofillFieldQualifier.identityEmail, + fullxpath: "/html/body/div[1]/div/div[1]/div/div/div[1]/form/div[1]/div/input", + }, + { + qualifierType: AutofillFieldQualifier.password, + fullxpath: "/html/body/div[1]/div/div[1]/div/div/div[1]/form/div[3]/div/div/input", + }, + ], + }, + { + domain: "samsung.com", + xpathQualifiers: [ + { + qualifierType: AutofillFieldQualifier.identityEmail, + fullxpath: "/html/body/div[1]/div/div[2]/div/div[2]/div[1]/form/div/div/input", + }, + { + qualifierType: AutofillFieldQualifier.password, + fullxpath: "/html/body/div[1]/div/div[2]/div[1]/div[3]/div[1]/form/div/div/input", + }, + ], + }, + ]; + + for (const matcher of matchers) { + if (matcher.domain === domain) { + for (const xpathQualifier of matcher.xpathQualifiers) { + const xpathResult = document.evaluate( + xpathQualifier.fullxpath, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + ); + + const { singleNodeValue: node } = xpathResult; + + if (node && node.isSameNode(element)) { + return { + domain, + fieldType: xpathQualifier.qualifierType, + xpathResult, + }; + } + } + } + } + } + /** * Caches the autofill field element and its data. * Will not cache the element if the index is less than 0. diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index b12017484eb..c1b3bb8c6ef 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AutofillFieldQualifier } from "../enums/autofill-field.enums"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import { getSubmitButtonKeywordsSet, sendExtensionMessage } from "../utils"; @@ -225,6 +226,7 @@ export class InlineMenuFieldQualificationService * @param pageDetails - The details of the page that the field is on. */ isFieldForLoginForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean { + console.log("isFieldForLoginForm", field); /** * Totp inline menu is available only for premium users. */ @@ -236,17 +238,22 @@ export class InlineMenuFieldQualificationService return true; } } + console.log("isFieldForLoginForm // premium disabled or not totp field w password type"); const isCurrentPasswordField = this.isCurrentPasswordField(field); if (isCurrentPasswordField) { - return this.isPasswordFieldForLoginForm(field, pageDetails); + console.log( + "isFieldForLoginForm // current password delegated to isPasswordFieldForLoginForm", + this.isPasswordFieldForLoginForm(field, pageDetails), + ); + return this.isPasswordFieldForLoginForm(field, pageDetails).result; } const isUsernameField = this.isUsernameField(field); if (!isUsernameField) { return false; } - + console.log("// was username field, delegated to isUsernameFieldForLoginForm"); return this.isUsernameFieldForLoginForm(field, pageDetails); } @@ -353,10 +360,21 @@ export class InlineMenuFieldQualificationService * @param _pageDetails - Currently unused, will likely be required in the future */ isFieldForIdentityForm(field: AutofillField, _pageDetails: AutofillPageDetails): boolean { + console.log( + "isFieldForIdentityForm // isExcludedFieldType // ", + this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet), + ); + if (this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet)) { return false; } - + console.log({ + isFieldForIdentityEmail: this.isFieldForIdentityEmail(field), + fieldContainsAutocompleteValues: this.fieldContainsAutocompleteValues( + field, + this.identityAutocompleteValues, + ), + }); return ( // Recognize explicit identity email fields (like id="new-email") this.isFieldForIdentityEmail(field) || @@ -373,23 +391,35 @@ export class InlineMenuFieldQualificationService private isPasswordFieldForLoginForm( field: AutofillField, pageDetails: AutofillPageDetails, - ): boolean { + ): { result: boolean; message: string } { + console.log({ field }); + const ns = "isPasswordFieldForLoginForm //"; + + if (field.domainMatch && field.domainMatch.fieldType === AutofillFieldQualifier.password) { + return { result: true, message: `${ns} Matched domain specific setting.` }; + } + const parentForm = pageDetails.forms[field.form]; // If the provided field is set with an autocomplete value of "current-password", we should assume that // the page developer intends for this field to be interpreted as a password field for a login form. if (this.fieldContainsAutocompleteValues(field, this.currentPasswordAutocompleteValue)) { if (!parentForm) { - return ( - pageDetails.fields.filter(this.isNewPasswordField).filter((f) => f.viewable).length === 0 - ); + const result = + pageDetails.fields.filter(this.isNewPasswordField).filter((f) => f.viewable).length === 0; + return { + result, + message: `${ns} Field had current password autocomplete value, result = 1+ viewable new password fields`, + }; } - return ( - pageDetails.fields - .filter(this.isNewPasswordField) - .filter((f) => f.viewable && f.form === field.form).length === 0 - ); + return { + result: + pageDetails.fields + .filter(this.isNewPasswordField) + .filter((f) => f.viewable && f.form === field.form).length === 0, + message: `${ns} Field had current password autocomplete value, result = 1+ viewable new password fields in form`, + }; } const usernameFieldsInPageDetails = pageDetails.fields.filter(this.isUsernameField); @@ -398,7 +428,7 @@ export class InlineMenuFieldQualificationService // If a single username and a single password field exists on the page, we // should assume that this field is part of a login form. if (usernameFieldsInPageDetails.length === 1 && passwordFieldsInPageDetails.length === 1) { - return true; + return { result: true, message: `${ns} single username and single password found` }; } // If the field is not structured within a form, we need to identify if the field is present on @@ -407,21 +437,27 @@ export class InlineMenuFieldQualificationService // If no parent form is found, and multiple password fields are present, we should assume that // the passed field belongs to a user account creation form. if (passwordFieldsInPageDetails.length > 1) { - return false; + return { + result: false, + message: `${ns} no parent form and multiple password fields present`, + }; } // If multiple username fields exist on the page, we should assume that // the provided field is part of an account creation form. const visibleUsernameFields = usernameFieldsInPageDetails.filter((f) => f.viewable); if (visibleUsernameFields.length > 1) { - return false; + return { + result: false, + message: `${ns} no parent form and multiple username fields found`, + }; } // If a single username field or less is present on the page, then we can assume that the // provided field is for a login form. This will only be the case if the field does not // explicitly have its autocomplete attribute set to "off" or "false". - - return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues); + const result = !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues); + return { result, message: `${ns} this field contains disabled values: ${result}` }; } // If the field has a form parent and there are multiple visible password fields @@ -430,7 +466,10 @@ export class InlineMenuFieldQualificationService (f) => f.form === field.form && f.viewable, ); if (visiblePasswordFieldsInPageDetails.length > 1) { - return false; + return { + result: false, + message: `${ns} Field has form parent and multiple password fields present`, + }; } // If the form has any visible username fields, we should treat the field as part of a login form @@ -438,12 +477,16 @@ export class InlineMenuFieldQualificationService (f) => f.form === field.form && f.viewable, ); if (visibleUsernameFields.length > 0) { - return true; + return { result: true, message: `${ns} Form has visible username fields` }; } // If the field has a form parent and no username field exists and the field has an // autocomplete attribute set to "off" or "false", this is not a password field - return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues); + const result = !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues); + return { + result, + message: `${ns} Field has form parent, no username, and contains disabled autocomplete values`, + }; } /** @@ -456,6 +499,12 @@ export class InlineMenuFieldQualificationService field: AutofillField, pageDetails: AutofillPageDetails, ): boolean { + console.log({ field }); + + if (field.domainMatch && field.domainMatch.fieldType === "identityEmail") { + return true; + } + // If the provided field is set with an autocomplete of "username", we should assume that // the page developer intends for this field to be interpreted as a username field. diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 614a5b014f2..4b970dba2c6 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -501,6 +501,25 @@ export function isInvalidResponseStatusCode(statusCode: number) { * Determines if the current context is within a sandboxed iframe. */ export function currentlyInSandboxedIframe(): boolean { + // console.log( + // 'String(self.origin).toLowerCase() === "null"', + // String(self.origin).toLowerCase() === "null", + // self, + // ); + // console.log( + // 'globalThis.frameElement?.hasAttribute("sandbox")', + // globalThis.frameElement?.hasAttribute("sandbox"), + // globalThis.frameElement, + // ); + // console.log( + // 'globalThis.location.hostname === ""', + // globalThis.location.hostname === "", + // globalThis.location, + // ); + // console.log({ + // globalThis, + // }); + return ( String(self.origin).toLowerCase() === "null" || globalThis.frameElement?.hasAttribute("sandbox") ||