1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 19:23:19 +00:00

[WIP] [spike] Allows XPath to override input selection by domain.

This commit is contained in:
Miles Blackwood
2025-08-19 14:43:41 -04:00
parent 321cd86a2c
commit 40043cc811
5 changed files with 241 additions and 34 deletions

View File

@@ -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
*/

View File

@@ -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");

View File

@@ -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<FillableFormFieldElement>).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<FormFieldElement>): 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.

View File

@@ -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.

View File

@@ -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") ||