[SG-1026 / PM-1125] - Document / Improve Form Detection in Notification Bar (#4798)

* SG-1026 - Documenting / slight refactoring of notification-bar - WIP

* SG-1026 - More documentation WIP

* SG-1026 - Continued documentation of notification bar + testing theories for specific sites as part of research to identify areas for possible improvement + added types where appropriate.

* SG-1026 - getSubmitButton docs

* SG-1026 - Autofill Service tweak - On account creation (ex: talkshoe.com), even if the pageDetails contained a valid form to watch, the loadPasswordFields method parameter for fillNewPassword being false for inputs with autoCompleteType of "new-password" would cause the account creation form to not be watched (null form data returned to notification bar). Setting this to true will help capture more account creations in the above specified scenario.

* SG-1026 - Additional documentation / comment clean up

* SG-1026 - Remove unused pageDetails array

* SG-1026 - These changes address form detection issues for the password change form on talkshoe.com:  (1) Update autofill.service getFormsWithPasswordFields(...) method to group autofill.js found password type fields under a single form in a very specific scenario where the most likely case is that it is a password change form with poorly designed mark up in a SPA (2) Notification bar - when listening to a form, we must use both the loginButtonNames and the changePasswordButton names as we don't know what type of form we are listening to (3) Notification bar - on page change, we must empty out the watched forms array to prevent forms w/ the same opId being added to the array on SPA url change (4) Notification bar - getSubmitButton update - If we cannot find a submit button within a form, try going up one level to the parent element and searching again (+ added save to changePasswordButtonNames). (5) Notification bar - when listening to a form with a submit button, we can attach the formOpId to the button so we can only have DOM traversal in one location and retrieve the form off the button later on in the form submission logic. For now, I'm just adding it as a fallback, but it could be the primary approach with more testing.

* SG-1026 - On first load of the notification-bar content script, we should start observing the DOM immediately so we properly catch rendered forms instead of waiting for a second. This was especially prevelant on refreshing the password change form page on talkshoe.com.

* SG-1026 - Due to the previous, timeout based nature of the calls to collectPageDetailsIfNeeded (now handlePageChange), the mutation observer could get setup late and miss forms loading (ex: refreshing a password change page on talkshoe.com). DOM observation is now setup as fast as possible on page load for SPAs/Non SPAs and on change for SPAs by having the mutation observer itself detect page change and deterministically calling handlePageChange().  However, with these changes, page detail collection still only occurs after a minimum of ~1 second whether or not it was triggered from the mutation observer detecting forms being injected onto the page or the scheduleHandlePageChange running (which has a theoretical maximum time to page detail collection of ~1.999 seconds but this does require the mutation observer to miss the page change in a SPA which shouldn't happen).

* SG-1026 - Identified issue with current form retrieval step in autofill service which prevents multi-step account creation forms from being returned to the notification-bar content script from the notification.background.ts script.

* SG-1026 - Add logic to formSubmitted to try and successfully process multi-step login form (email then password on https://login.live.com/login.srf) with next button that gets swapped out for a true submit button in order to prompt for saving user credentials if not in Bitwarden. This logic works *sometimes* as the submit button page change often stops the submit button event listeners from being able to fire and send the login to the background script. However, that is a separate issue to be solved, and sometimes is better than never. This type of logic might be useful in solving the multi-step account creation form on https://signup.live.com/signup but that will require additional changes to the autofill service which current intercepts forms without passwords and prevents them from reaching the notification-bar.ts content script.

* SG-1026 - Add note explaining the persistence of the content script

* SG-1026 - Update stack overflow link to improve clarity.

---------

Co-authored-by: Robyn MacCallum <robyntmaccallum@gmail.com>
This commit is contained in:
Jared Snider 2023-04-13 15:59:31 -04:00 committed by GitHub
parent 0bc6add5c3
commit b3d4d9898e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 534 additions and 106 deletions

View File

@ -1,15 +1,44 @@
import AddLoginRuntimeMessage from "../../background/models/addLoginRuntimeMessage";
import ChangePasswordRuntimeMessage from "../../background/models/changePasswordRuntimeMessage";
import AutofillField from "../models/autofill-field";
import { WatchedForm } from "../models/watched-form";
import { FormData } from "../services/abstractions/autofill.service";
interface HTMLElementWithFormOpId extends HTMLElement {
formOpId: string;
}
/**
* @fileoverview This file contains the code for the Bitwarden Notification Bar content script.
* The notification bar is used to notify logged in users that they can
* save a new login, change a existing password on a password change screen,
* or update an existing login after detecting a different password on login.
*
* Note: content scripts are reloaded on non-SPA page change.
*/
/*
* Run content script when the DOM is fully loaded
*
* The DOMContentLoaded event fires when the HTML document has been completely parsed,
* and all deferred scripts (<script defer src="…"> and <script type="module">) have
* downloaded and executed. It doesn't wait for other things like images, subframes,
* and async scripts to finish loading.
* https://developer.mozilla.org/en-US/docs/Web/API/Window/DOMContentLoaded_event
*/
document.addEventListener("DOMContentLoaded", (event) => {
// Do not show the notification bar on the Bitwarden vault
// because they can add logins and change passwords there
if (window.location.hostname.endsWith("vault.bitwarden.com")) {
return;
}
const pageDetails: any[] = [];
const formData: any[] = [];
// Initialize required variables and set default values
const watchedForms: WatchedForm[] = [];
let barType: string = null;
let pageHref: string = null;
// Provides the ability to watch for changes being made to the DOM tree.
let observer: MutationObserver = null;
const observeIgnoredElements = new Set([
"a",
@ -24,9 +53,10 @@ document.addEventListener("DOMContentLoaded", (event) => {
"em",
"hr",
]);
let domObservationCollectTimeout: number = null;
let collectIfNeededTimeout: number = null;
let observeDomTimeout: number = null;
let domObservationCollectTimeoutId: number = null;
let collectPageDetailsTimeoutId: number = null;
let handlePageChangeTimeoutId: number = null;
const inIframe = isInIframe();
const cancelButtonNames = new Set(["cancel", "close", "back"]);
const logInButtonNames = new Set([
@ -43,11 +73,16 @@ document.addEventListener("DOMContentLoaded", (event) => {
"update password",
"change password",
"change",
"save",
]);
const changePasswordButtonContainsNames = new Set(["pass", "change", "contras", "senha"]);
// These are preferences for whether to show the notification bar based on the user's settings
// and they are set in the Settings > Options page in the browser extension.
let disabledAddLoginNotification = false;
let disabledChangedPasswordNotification = false;
// Look up the active user id from storage
const activeUserIdKey = "activeUserId";
let activeUserId: string;
chrome.storage.local.get(activeUserIdKey, (obj: any) => {
@ -57,32 +92,59 @@ document.addEventListener("DOMContentLoaded", (event) => {
activeUserId = obj[activeUserIdKey];
});
// Look up the user's settings from storage
chrome.storage.local.get(activeUserId, (obj: any) => {
if (obj?.[activeUserId] == null) {
return;
}
const domains = obj[activeUserId].settings.neverDomains;
// eslint-disable-next-line
if (domains != null && domains.hasOwnProperty(window.location.hostname)) {
const userSettings = obj[activeUserId].settings;
// NeverDomains is a dictionary of domains that the user has chosen to never
// show the notification bar on (for login detail collection or password change).
// It is managed in the Settings > Excluded Domains page in the browser extension.
// Example: '{"bitwarden.com":null}'
const excludedDomainsDict = userSettings.neverDomains;
if (
excludedDomainsDict != null &&
// eslint-disable-next-line
excludedDomainsDict.hasOwnProperty(window.location.hostname)
) {
return;
}
disabledAddLoginNotification = obj[activeUserId].settings.disableAddLoginNotification;
disabledChangedPasswordNotification =
obj[activeUserId].settings.disableChangedPasswordNotification;
// Set local disabled preferences
disabledAddLoginNotification = userSettings.disableAddLoginNotification;
disabledChangedPasswordNotification = userSettings.disableChangedPasswordNotification;
if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) {
collectIfNeededWithTimeout();
// If the user has not disabled both notifications, then handle the initial page change (null -> actual page)
handlePageChange();
}
});
//#region Message Processing
// Listen for messages from the background script
// Note: onMessage events are fired when a message is sent from either an extension process
// (by runtime.sendMessage) or a content script (by tabs.sendMessage).
// https://developer.chrome.com/docs/extensions/reference/runtime/#event-onMessage
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
processMessages(msg, sendResponse);
});
/**
* Processes messages received from the background script via the `chrome.runtime.onMessage` event.
* @param {Object} msg - The received message.
* @param {Function} sendResponse - The function used to send a response back to the background script.
* @returns {boolean} - Returns `true` if a response was sent, `false` otherwise.
*/
function processMessages(msg: any, sendResponse: (response?: any) => void) {
if (msg.command === "openNotificationBar") {
// `notification.background.ts : doNotificationQueueCheck(...)` sends
// a message to the content script to open the notification bar
// on Login Add or Password Change
if (inIframe) {
return;
}
@ -90,6 +152,10 @@ document.addEventListener("DOMContentLoaded", (event) => {
sendResponse();
return true;
} else if (msg.command === "closeNotificationBar") {
// The following methods send a message to the content script to close the notification bar:
// `bar.js : closeButton click` > `notification.background.ts : processMessage(...)`
// `notification.background.ts : saveNever(...)`
// `notification.background.ts : saveOrUpdateCredentials(...)`
if (inIframe) {
return;
}
@ -97,6 +163,8 @@ document.addEventListener("DOMContentLoaded", (event) => {
sendResponse();
return true;
} else if (msg.command === "adjustNotificationBar") {
// `bar.js : window resize` > `notification.background.ts : processMessage(...)`
// sends a message to the content script to adjust the notification bar
if (inIframe) {
return;
}
@ -104,52 +172,79 @@ document.addEventListener("DOMContentLoaded", (event) => {
sendResponse();
return true;
} else if (msg.command === "notificationBarPageDetails") {
pageDetails.push(msg.data.details);
// Note: we deliberately do not check for inIframe here because a lot of websites
// embed their login forms into iframes
// Ex: icloud.com uses a login form in an iframe from apple.com
// See method collectPageDetails() for full call itinerary that leads to this message
watchForms(msg.data.forms);
sendResponse();
return true;
}
}
//#endregion Message Processing
function isInIframe() {
try {
return window.self !== window.top;
} catch {
return true;
}
}
/**
* Observe the DOM for changes and collect page details if forms are added to the page
*/
function observeDom() {
const bodies = document.querySelectorAll("body");
if (bodies && bodies.length > 0) {
observer = new MutationObserver((mutations) => {
if (mutations == null || mutations.length === 0 || pageHref !== window.location.href) {
observer = new MutationObserver((mutations: MutationRecord[]) => {
// If mutation observer detects a change in the page URL, collect page details
// which will reset the observer and start watching for new forms on the new page
if (pageHref !== window.location.href) {
handlePageChange();
return;
}
let doCollect = false;
// If mutations are not found, return
if (mutations == null || mutations.length === 0) {
return;
}
let doCollectPageDetails = false;
for (let i = 0; i < mutations.length; i++) {
const mutation = mutations[i];
const mutation: MutationRecord = mutations[i];
// If there are no added nodes, continue to next mutation
if (mutation.addedNodes == null || mutation.addedNodes.length === 0) {
continue;
}
for (let j = 0; j < mutation.addedNodes.length; j++) {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
const addedNode: any = mutation.addedNodes[j];
// If the added node is null, continue to next added node
if (addedNode == null) {
continue;
}
// Get the lowercase tag name of the added node (if it exists)
const tagName = addedNode.tagName != null ? addedNode.tagName.toLowerCase() : null;
// If tag name exists & is a form &
// (either the dataset is null or it does not have the custom data attribute: "data-bitwarden-watching"),
// then collect page details and break
// Note: The dataset read-only property of the HTMLElement interface provides
// read/write access to custom data attributes (data-*) on elements
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
if (
tagName != null &&
tagName === "form" &&
(addedNode.dataset == null || !addedNode.dataset.bitwardenWatching)
) {
doCollect = true;
doCollectPageDetails = true;
break;
}
// If tag name exists & is in the observeIgnoredElements set
// or if the added node does not have the querySelectorAll method, continue to next added node
// Note: querySelectorAll(...) exists on the Element & Document interfaces
// It doesn't exist for nodes that are not elements, such as text nodes
// Text Node examples: https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeName#example
if (
(tagName != null && observeIgnoredElements.has(tagName)) ||
addedNode.querySelectorAll == null
@ -157,140 +252,249 @@ document.addEventListener("DOMContentLoaded", (event) => {
continue;
}
// If the added node has any descendent form elements that are not yet being watched, collect page details and break
const forms = addedNode.querySelectorAll("form:not([data-bitwarden-watching])");
if (forms != null && forms.length > 0) {
doCollect = true;
doCollectPageDetails = true;
break;
}
}
if (doCollect) {
if (doCollectPageDetails) {
break;
}
}
if (doCollect) {
if (domObservationCollectTimeout != null) {
window.clearTimeout(domObservationCollectTimeout);
// If page details need to be collected, clear any existing timeout and schedule a new one
if (doCollectPageDetails) {
if (domObservationCollectTimeoutId != null) {
window.clearTimeout(domObservationCollectTimeoutId);
domObservationCollectTimeoutId = null;
}
domObservationCollectTimeout = window.setTimeout(collect, 1000);
// The timeout is used to avoid collecting page details too often on page mutation while also
// giving the DOM time to settle down after a change (ex: multi-part forms being rendered)
domObservationCollectTimeoutId = window.setTimeout(collectPageDetails, 1000);
}
});
// Watch all mutations to the body element and all of its children & descendants
observer.observe(bodies[0], { childList: true, subtree: true });
}
}
function collectIfNeededWithTimeout() {
if (collectIfNeededTimeout != null) {
window.clearTimeout(collectIfNeededTimeout);
}
collectIfNeededTimeout = window.setTimeout(collectIfNeeded, 1000);
}
function collectIfNeeded() {
/**
* Handles initial page load and page changes
* 3 ways this method is called:
*
* (1) On initial content script load
*
* (2) On page change (detected by observer)
*
* (3) On after scheduled delay setup in `scheduleHandlePageChange()
*
* On page change, we update the page href, empty the watched forms array, call collectPageDetails (w/ 1 second timeout), and reset the observer
*/
function handlePageChange() {
// On first load the content script or any time the page changes, we need to collect the page details and setup the mutation observer
if (pageHref !== window.location.href) {
// update href
pageHref = window.location.href;
// Empty watched forms so it doesn't carry over between SPA page changes
// This allows formOpIds to be unique for each page so that we can
// associate submit buttons with their respective forms in the getSubmitButton logic.
watchedForms.length = 0;
// collect the page details after a timeout
// The timeout is used to allow more time for the page to load before collecting the page details
// as there are some cases where SPAs do not load the entire page on initial load, so we need to wait
if (collectPageDetailsTimeoutId != null) {
window.clearTimeout(collectPageDetailsTimeoutId);
collectPageDetailsTimeoutId = null;
}
collectPageDetailsTimeoutId = window.setTimeout(collectPageDetails, 1000);
if (observer) {
// reset existing DOM mutation observer so it can listen for changes to the new page body
observer.disconnect();
observer = null;
}
collect();
if (observeDomTimeout != null) {
window.clearTimeout(observeDomTimeout);
}
observeDomTimeout = window.setTimeout(observeDom, 1000);
// On first load or page change, start observing the DOM as early as possible
// to avoid missing any forms that are added after the page loads
observeDom();
}
if (collectIfNeededTimeout != null) {
window.clearTimeout(collectIfNeededTimeout);
}
collectIfNeededTimeout = window.setTimeout(collectIfNeeded, 1000);
// This is a safeguard in case the observer misses a SPA page change.
scheduleHandlePageChange();
}
function collect() {
/**
* Set up a timeout to call handlePageChange after 1 second
*/
function scheduleHandlePageChange() {
// Check again in 1 second (but clear any existing timeout first)
if (handlePageChangeTimeoutId != null) {
window.clearTimeout(handlePageChangeTimeoutId);
handlePageChangeTimeoutId = null;
}
handlePageChangeTimeoutId = window.setTimeout(handlePageChange, 1000);
}
/** *
* Tell the background script to collect the page details.
*
* (1) Sends a message with command `bgCollectPageDetails` to `runtime.background.ts : processMessage(...)`
*
* (2) `runtime.background.ts : processMessage(...)` calls
* `main.background.ts : collectPageDetailsForContentScript`
*
* (3) `main.background.ts : collectPageDetailsForContentScript`
* sends a message with command `collectPageDetails` to the `autofill.js` content script
*
* (4) `autofill.js` content script runs a `collect(document)` method.
* The result is sent via message with command `collectPageDetailsResponse` to `notification.background.ts : processMessage(...)`
*
* (5) `notification.background.ts : processMessage(...)` gathers forms with password fields and passes them and the page details
* via message with command `notificationBarPageDetails` back to the `processMessages` method in this content script.
*
* */
function collectPageDetails() {
sendPlatformMessage({
command: "bgCollectPageDetails",
sender: "notificationBar",
});
}
function watchForms(forms: any[]) {
//#endregion Page Detail Collection Methods
// #region Form Detection and Submission Handling
/**
* Iterates through the given array of forms and adds an event listener to each form.
* The purpose of the event listener is to detect changes in form data and store the changes.
*
* Note: The forms were gathered in the `notification.background.ts : processMessage(...)`
* method with command `collectPageDetailsResponse` by the `autofillService.getFormsWithPasswordFields(...)` method
* and passed to the `processMessages` method in this content script.
*
* @param {FormData[]} forms - The array of forms to be watched.
*/
function watchForms(forms: FormData[]) {
// If there are no forms, return
if (forms == null || forms.length === 0) {
return;
}
forms.forEach((f: any) => {
forms.forEach((f: FormData) => {
// Get the form element by id
const formId: string = f.form != null ? f.form.htmlID : null;
let formEl: HTMLFormElement = null;
if (formId != null && formId !== "") {
formEl = document.getElementById(formId) as HTMLFormElement;
}
// If the form could not be retrieved by its HTML ID, retrieve it by its index pulled from the opid
if (formEl == null) {
// opid stands for OnePassword ID - uniquely ID's an element on a page
// and is generated in `autofill.js`
// Each form has an opid and each element has an opid and its parent form opid
const index = parseInt(f.form.opid.split("__")[2], null);
formEl = document.getElementsByTagName("form")[index];
}
// If the form element exists and is not yet being watched, start watching it and set it as watched
if (formEl != null && formEl.dataset.bitwardenWatching !== "1") {
const formDataObj: any = {
const watchedForm: WatchedForm = {
data: f,
formEl: formEl,
usernameEl: null,
passwordEl: null,
passwordEls: null,
};
locateFields(formDataObj);
formData.push(formDataObj);
listen(formEl);
// Locate the username and password fields
locateFields(watchedForm);
// Add the form data to the array of watched forms
watchedForms.push(watchedForm);
// Add an event listener to the form
listenToForm(formEl);
// Set the form as watched
formEl.dataset.bitwardenWatching = "1";
}
});
}
function listen(form: HTMLFormElement) {
function listenToForm(form: HTMLFormElement) {
// Remove any existing event listeners and re-add them
// for form submission and submit button click
form.removeEventListener("submit", formSubmitted, false);
form.addEventListener("submit", formSubmitted, false);
const submitButton = getSubmitButton(form, logInButtonNames);
findAndListenToSubmitButton(form);
}
function findAndListenToSubmitButton(form: HTMLFormElement) {
// Use login button names and change password names since we don't
// know what type of form we are watching
const submitButton = getSubmitButton(
form,
unionSets(logInButtonNames, changePasswordButtonNames)
);
if (submitButton != null) {
submitButton.removeEventListener("click", formSubmitted, false);
submitButton.addEventListener("click", formSubmitted, false);
// Associate the form opid with the submit button so we can find the form on submit.
(submitButton as HTMLElementWithFormOpId).formOpId = form.opid;
}
}
function locateFields(formDataObj: any) {
/**
* Locate the fields within a form element given form data.
* @param {Object} watchedForm - The object containing form data and the form element to search within.
*/
function locateFields(watchedForm: WatchedForm) {
// Get all input elements
const inputs = Array.from(document.getElementsByTagName("input"));
formDataObj.usernameEl = locateField(formDataObj.formEl, formDataObj.data.username, inputs);
if (formDataObj.usernameEl != null && formDataObj.data.password != null) {
formDataObj.passwordEl = locatePassword(
formDataObj.formEl,
formDataObj.data.password,
// Locate the username field
watchedForm.usernameEl = locateField(watchedForm.formEl, watchedForm.data.username, inputs);
// if we found a username field, try to locate a single password field
if (watchedForm.usernameEl != null && watchedForm.data.password != null) {
// This is most likely a login or create account form b/c we have a username and password
watchedForm.passwordEl = locatePassword(
watchedForm.formEl,
watchedForm.data.password,
inputs,
true
true // Only do fallback if we have expect to find a single password field
);
} else if (formDataObj.data.passwords != null) {
formDataObj.passwordEls = [];
formDataObj.data.passwords.forEach((pData: any) => {
const el = locatePassword(formDataObj.formEl, pData, inputs, false);
if (el != null) {
formDataObj.passwordEls.push(el);
} else if (watchedForm.data.passwords != null) {
// if we didn't find a username field, try to locate multiple password fields
// This is most likely a change password form b/c we have multiple password fields
watchedForm.passwordEls = [];
watchedForm.data.passwords.forEach((passwordData: AutofillField) => {
// Note: do not do fallback here b/c we expect to find multiple password fields
// and form.querySelector always returns the first element it finds
const passwordEl = locatePassword(watchedForm.formEl, passwordData, inputs, false);
if (passwordEl != null) {
watchedForm.passwordEls.push(passwordEl);
}
});
if (formDataObj.passwordEls.length === 0) {
formDataObj.passwordEls = null;
if (watchedForm.passwordEls.length === 0) {
watchedForm.passwordEls = null;
}
}
}
function locatePassword(
form: HTMLFormElement,
passwordData: any,
passwordData: AutofillField,
inputs: HTMLInputElement[],
doLastFallback: boolean
) {
): HTMLInputElement {
let el = locateField(form, passwordData, inputs);
if (el != null && el.type !== "password") {
el = null;
@ -301,10 +505,23 @@ document.addEventListener("DOMContentLoaded", (event) => {
return el;
}
function locateField(form: HTMLFormElement, fieldData: any, inputs: HTMLInputElement[]) {
/**
* Locate a field within a form element given field data.
* @param {Object} form - The form element to search within.
* @param {Object} fieldData - The field data to search for.
* @param {Object[]} inputs - The array of input elements to search within.
* @returns {Object} The located field element.
*/
function locateField(
form: HTMLFormElement,
fieldData: AutofillField,
inputs: HTMLInputElement[]
): HTMLInputElement | null {
// If we have no field data, we cannot locate the field
if (fieldData == null) {
return;
}
// Try to locate the field by its HTML ID, by its HTML name, or finally by its element number
let el: HTMLInputElement = null;
if (fieldData.htmlID != null && fieldData.htmlID !== "") {
try {
@ -322,12 +539,26 @@ document.addEventListener("DOMContentLoaded", (event) => {
return el;
}
/*
* Event handler for form submission (submit button click or form submit)
*/
function formSubmitted(e: Event) {
let form: HTMLFormElement = null;
// If the event is a click event, we need to find the closest form element
let clickedElement: HTMLElement = null;
if (e.type === "click") {
form = (e.target as HTMLElement).closest("form");
clickedElement = e.target as HTMLElement;
// Set a flag on the clicked element so we don't set it as a submit button again
if (clickedElement?.dataset?.bitwardenClicked !== "1") {
clickedElement.dataset.bitwardenClicked = "1";
}
form = clickedElement.closest("form");
// If we didn't find a form element, check if the click was within a modal
if (form == null) {
const parentModal = (e.target as HTMLElement).closest("div.modal");
const parentModal = clickedElement.closest("div.modal");
// If we found a modal, check if it has a single form element
if (parentModal != null) {
const modalForms = parentModal.querySelectorAll("form");
if (modalForms.length === 1) {
@ -335,75 +566,120 @@ document.addEventListener("DOMContentLoaded", (event) => {
}
}
}
// see if the event target is a submit button with a formOpId
const formOpId = (clickedElement as HTMLElementWithFormOpId).formOpId;
if (form == null && formOpId != null) {
// Find form in watched forms array via form op id
form = watchedForms.find((wf: WatchedForm) => wf.formEl.opid === formOpId).formEl;
}
} else {
// If the event is a submit event, we can get the form element from the event target
form = e.target as HTMLFormElement;
}
// if we didn't find a form element or we've already processed this form, return
if (form == null || form.dataset.bitwardenProcessed === "1") {
return;
}
for (let i = 0; i < formData.length; i++) {
if (formData[i].formEl !== form) {
// Find the form in the watched forms array
for (let i = 0; i < watchedForms.length; i++) {
if (watchedForms[i].formEl !== form) {
continue;
}
const disabledBoth = disabledChangedPasswordNotification && disabledAddLoginNotification;
if (!disabledBoth && formData[i].usernameEl != null && formData[i].passwordEl != null) {
// if user has not disabled both notifications and we have a username and password field,
if (
!disabledBoth &&
watchedForms[i].usernameEl != null &&
watchedForms[i].passwordEl != null
) {
// Create a login object from the form data
const login: AddLoginRuntimeMessage = {
username: formData[i].usernameEl.value,
password: formData[i].passwordEl.value,
username: watchedForms[i].usernameEl.value,
password: watchedForms[i].passwordEl.value,
url: document.URL,
};
if (
login.username != null &&
login.username !== "" &&
login.password != null &&
login.password !== ""
) {
// if we have values for username and password, send a message to the background script to add the login
const userNamePopulated = login.username != null && login.username !== "";
const passwordPopulated = login.password != null && login.password !== "";
if (userNamePopulated && passwordPopulated) {
processedForm(form);
sendPlatformMessage({
command: "bgAddLogin",
login: login,
});
break;
} else if (
userNamePopulated &&
!passwordPopulated &&
clickedElement !== null &&
!isElementVisible(clickedElement)
) {
// Likely a multi step login form with password missing and next button no longer visible
// Remove click listener from previous "submit" button (next button)
clickedElement.removeEventListener("click", formSubmitted);
findAndListenToSubmitButton(form);
}
}
if (!disabledChangedPasswordNotification && formData[i].passwordEls != null) {
const passwords: string[] = formData[i].passwordEls
// if user has not disabled the password changed notification and we have multiple password fields,
// then check if the user has changed their password
if (!disabledChangedPasswordNotification && watchedForms[i].passwordEls != null) {
// Get the values of the password fields
const passwords: string[] = watchedForms[i].passwordEls
.filter((el: HTMLInputElement) => el.value != null && el.value !== "")
.map((el: HTMLInputElement) => el.value);
let curPass: string = null;
let newPass: string = null;
let newPassOnly = false;
if (formData[i].passwordEls.length === 3 && passwords.length === 3) {
if (watchedForms[i].passwordEls.length === 3 && passwords.length === 3) {
// we have 3 password fields and all 3 have values
// Assume second field is new password.
newPass = passwords[1];
if (passwords[0] !== newPass && newPass === passwords[2]) {
// first field is the current password, the second field is the new password, and the third field is the new password confirmation
curPass = passwords[0];
} else if (newPass !== passwords[2] && passwords[0] === newPass) {
// first field is the new password, second field is the new password confirmation, and third field is the current password
curPass = passwords[2];
}
} else if (formData[i].passwordEls.length === 2 && passwords.length === 2) {
} else if (watchedForms[i].passwordEls.length === 2 && passwords.length === 2) {
// we have 2 password fields and both have values
if (passwords[0] === passwords[1]) {
// both fields have the same value, assume this is a new password
newPassOnly = true;
newPass = passwords[0];
curPass = null;
} else {
// both fields have different values
// Check if the submit button contains any of the change password button names as a safeguard
const buttonText = getButtonText(getSubmitButton(form, changePasswordButtonNames));
const matches = Array.from(changePasswordButtonContainsNames).filter(
(n) => buttonText.indexOf(n) > -1
);
if (matches.length > 0) {
// If there is a change password button, then
// assume first field is current password and second field is new password
curPass = passwords[0];
newPass = passwords[1];
}
}
}
// if we have a new password and a current password or we only have a new password
if ((newPass != null && curPass != null) || (newPassOnly && newPass != null)) {
// Flag the form as processed so we don't process it again
processedForm(form);
// Send a message to the `notification.background.ts` background script to notify the user that their password has changed
// which eventually calls the `processMessage(...)` method in this script with command `openNotificationBar`
const changePasswordRuntimeMessage: ChangePasswordRuntimeMessage = {
newPassword: newPass,
currentPassword: curPass,
@ -419,25 +695,46 @@ document.addEventListener("DOMContentLoaded", (event) => {
}
}
function getSubmitButton(wrappingEl: HTMLElement, buttonNames: Set<string>) {
/**
* Gets a submit button element from a form or enclosing element
* @param wrappingEl - the form or enclosing element
* @param buttonNames - login button names to match against
* @returns the submit button element
*/
function getSubmitButton(wrappingEl: HTMLElement, buttonNames: Set<string>): HTMLElement {
// If wrapping element doesn't exist we can't get a submit button
if (wrappingEl == null) {
return null;
}
const wrappingElIsForm = wrappingEl.tagName.toLowerCase() === "form";
let submitButton = wrappingEl.querySelector(
'input[type="submit"], input[type="image"], ' + 'button[type="submit"]'
) as HTMLElement;
// query for submit button
const possibleSubmitBtnSelectors = [
'input[type="submit"]',
'input[type="image"]',
'button[type="submit"]',
];
const submitBtnSelector = possibleSubmitBtnSelectors
.map((btnSelector) => `${btnSelector}:not([data-bitwarden-clicked])`)
.join(", ");
let submitButton = wrappingEl.querySelector(submitBtnSelector) as HTMLElement;
// if we didn't find a submit button and we are in a form:
if (submitButton == null && wrappingElIsForm) {
submitButton = wrappingEl.querySelector("button:not([type])");
// query for a button that doesn't have the type attribute
submitButton = wrappingEl.querySelector("button:not([type]):not([data-bitwarden-clicked])");
if (submitButton != null) {
// Retrieve "submit" button text because it might be a cancel button instead of a submit button.
// If it is a cancel button, then we don't want to use it.
const buttonText = getButtonText(submitButton);
if (buttonText != null && cancelButtonNames.has(buttonText.trim().toLowerCase())) {
submitButton = null;
}
}
}
// If we still don't have a submit button, then try to find a button that looks like a submit button
if (submitButton == null) {
const possibleSubmitButtons = Array.from(
wrappingEl.querySelectorAll(
@ -445,12 +742,18 @@ document.addEventListener("DOMContentLoaded", (event) => {
)
) as HTMLElement[];
let typelessButton: HTMLElement = null;
// Loop through all possible submit buttons and find the first one that matches a submit button name
possibleSubmitButtons.forEach((button) => {
if (submitButton != null || button == null || button.tagName == null) {
// Continue if we already found a submit button or if the button is null or doesn't have a tag name
// Return in a forEach(...) is equivalent to continue
return;
}
// Retrieve button text
const buttonText = getButtonText(button);
if (buttonText != null) {
// if we have a button that doesn't have a type attribute & isn't a cancel btn,
// then save it in case we don't find a submit button
if (
typelessButton != null &&
button.tagName.toLowerCase() === "button" &&
@ -459,16 +762,21 @@ document.addEventListener("DOMContentLoaded", (event) => {
) {
typelessButton = button;
} else if (buttonNames.has(buttonText.trim().toLowerCase())) {
// If the button text matches a submit button name, then use it
submitButton = button;
}
}
});
// Fallback to typeless button if it exists and we didn't find a submit button
if (submitButton == null && typelessButton != null) {
submitButton = typelessButton;
}
}
// If we still don't have a submit button, then try to find a submit button in a modal
if (submitButton == null && wrappingElIsForm) {
// Maybe it's in a modal?
// Find closest modal and check if it has only one form
const parentModal = wrappingEl.closest("div.modal") as HTMLElement;
if (parentModal != null) {
const modalForms = parentModal.querySelectorAll("form");
@ -476,10 +784,26 @@ document.addEventListener("DOMContentLoaded", (event) => {
submitButton = getSubmitButton(parentModal, buttonNames);
}
}
// If we still don't have a submit button, then try to find a submit button by using the form's
// parent element as the wrapping element
if (submitButton == null) {
const parentElement = wrappingEl.parentElement;
// Going up a level and looking for loginButtonNames
if (parentElement != null) {
submitButton = getSubmitButton(parentElement, buttonNames);
}
}
}
return submitButton;
}
/**
* Returns the text of a given button element.
* @param button - The button element to get the text from.
* @returns - The text of the button.
*/
function getButtonText(button: HTMLElement) {
let buttonText: string = null;
if (button.tagName.toLowerCase() === "input") {
@ -490,6 +814,10 @@ document.addEventListener("DOMContentLoaded", (event) => {
return buttonText;
}
/**
* Mark form as processed so we don't try to process it again.
* @param {Object} form - The form element to mark as processed.
*/
function processedForm(form: HTMLFormElement) {
form.dataset.bitwardenProcessed = "1";
window.setTimeout(() => {
@ -497,6 +825,9 @@ document.addEventListener("DOMContentLoaded", (event) => {
}, 500);
}
//#endregion Form Detection and Submission Handling
//#region Notification Bar Functions (open, close, height adjustment, etc.)
function closeExistingAndOpenBar(type: string, typeData: any) {
const barQueryParams = {
type,
@ -593,8 +924,73 @@ document.addEventListener("DOMContentLoaded", (event) => {
el.style.height = heightStyle;
}
}
//#endregion Notification Bar Functions (open, close, height adjustment, etc.)
//#region Helper Functions
function sendPlatformMessage(msg: any) {
chrome.runtime.sendMessage(msg);
}
function isInIframe() {
try {
return window.self !== window.top;
} catch {
return true;
}
}
// https://stackoverflow.com/a/41328397/20715409 - most efficient of the answers there
function unionSets(...iterables: Set<any>[]): Set<any> {
const set = new Set();
for (const iterable of iterables) {
for (const item of iterable) {
set.add(item);
}
}
return set;
}
/**
* Determine if the element is visible.
* Visible is define as not having `display: none` or `visibility: hidden`.
* @param {HTMLElement} el
* @returns {boolean} Returns `true` if the element is visible and `false` otherwise
*
* Copied from autofill.js and converted to TypeScript;
* TODO: could be refactored to be in a shared location if autofill.js is converted to TS
*/
function isElementVisible(el: HTMLElement): boolean {
let theEl: Node | null = el;
// Get the top level document
const elDocument = el.ownerDocument;
const elWindow = elDocument ? elDocument.defaultView : undefined;
// walk the dom tree until we reach the top
while (theEl && theEl !== document) {
// Calculate the style of the element
const elStyle = elWindow?.getComputedStyle
? elWindow.getComputedStyle(theEl as HTMLElement, null)
: (theEl as HTMLElement).style;
// If there's no computed style at all, we're done, as we know that it's not hidden
if (!elStyle) {
return true;
}
// If the element's computed style includes `display: none` or `visibility: hidden`, we know it's hidden
if ("none" === elStyle.display || "hidden" === elStyle.visibility) {
return false;
}
// At this point, we aren't sure if the element is hidden or not, so we need to keep walking up the tree
theEl = theEl.parentNode;
}
// If we've reached the top of the tree, we know that the element is visible
return theEl === document;
}
//#endregion Helper Functions
});

View File

@ -0,0 +1,8 @@
import { FormData } from "../services/abstractions/autofill.service";
export interface WatchedForm {
data: FormData;
formEl: HTMLFormElement;
usernameEl: HTMLInputElement | null;
passwordEl: HTMLInputElement | null;
passwordEls: HTMLInputElement[] | null;
}

View File

@ -50,17 +50,41 @@ export default class AutofillService implements AutofillServiceInterface {
getFormsWithPasswordFields(pageDetails: AutofillPageDetails): FormData[] {
const formData: FormData[] = [];
const passwordFields = AutofillService.loadPasswordFields(
pageDetails,
true,
true,
false,
false
);
const passwordFields = AutofillService.loadPasswordFields(pageDetails, true, true, false, true);
// TODO: this logic prevents multi-step account creation forms (that just start with email)
// from being passed on to the notification bar content script - even if autofill.js found the form and email field.
// ex: https://signup.live.com/
if (passwordFields.length === 0) {
return formData;
}
// Back up check for cases where there are several password fields detected,
// but they are not all part of the form b/c of bad HTML
// gather password fields that don't have an enclosing form
const passwordFieldsWithoutForm = passwordFields.filter((pf) => pf.form === undefined);
const formKeys = Object.keys(pageDetails.forms);
const formCount = formKeys.length;
// if we have 3 password fields and only 1 form, and there are password fields that are not within a form
// but there is at least one password field within the form, then most likely this is a poorly built password change form
if (passwordFields.length === 3 && formCount == 1 && passwordFieldsWithoutForm.length > 0) {
// Only one form so get the singular form key
const soloFormKey = formKeys[0];
const atLeastOnePasswordFieldWithinSoloForm =
passwordFields.filter((pf) => pf.form !== null && pf.form === soloFormKey).length > 0;
if (atLeastOnePasswordFieldWithinSoloForm) {
// We have a form with at least one password field,
// so let's make an assumption that the password fields without a form are actually part of this form
passwordFieldsWithoutForm.forEach((pf) => {
pf.form = soloFormKey;
});
}
}
for (const formKey in pageDetails.forms) {
// eslint-disable-next-line
if (!pageDetails.forms.hasOwnProperty(formKey)) {