mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-28 04:08:47 +02:00
28de9439be
* [deps] Autofill: Update prettier to v3 * prettier formatting updates --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
1010 lines
38 KiB
TypeScript
1010 lines
38 KiB
TypeScript
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";
|
|
import { GlobalSettings, UserSettings } from "../types";
|
|
|
|
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
|
|
*/
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", loadNotificationBar);
|
|
} else {
|
|
loadNotificationBar();
|
|
}
|
|
|
|
async function loadNotificationBar() {
|
|
// 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",
|
|
"i",
|
|
"b",
|
|
"strong",
|
|
"span",
|
|
"code",
|
|
"br",
|
|
"img",
|
|
"small",
|
|
"em",
|
|
"hr",
|
|
]);
|
|
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([
|
|
"log in",
|
|
"sign in",
|
|
"login",
|
|
"go",
|
|
"submit",
|
|
"continue",
|
|
"next",
|
|
]);
|
|
const changePasswordButtonNames = new Set([
|
|
"save password",
|
|
"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;
|
|
let showNotificationBar = true;
|
|
|
|
// Look up the active user id from storage
|
|
const activeUserIdKey = "activeUserId";
|
|
let activeUserId: string;
|
|
|
|
const activeUserStorageValue = await getFromLocalStorage(activeUserIdKey);
|
|
if (activeUserStorageValue[activeUserIdKey]) {
|
|
activeUserId = activeUserStorageValue[activeUserIdKey];
|
|
}
|
|
|
|
// Look up the user's settings from storage
|
|
const userSettingsStorageValue = await getFromLocalStorage(activeUserId);
|
|
if (userSettingsStorageValue[activeUserId]) {
|
|
const userSettings: UserSettings = userSettingsStorageValue[activeUserId].settings;
|
|
const globalSettings: GlobalSettings = await getFromLocalStorage("global");
|
|
|
|
// Do not show the notification bar on the Bitwarden vault
|
|
// because they can add logins and change passwords there
|
|
if (window.location.origin === userSettings.serverConfig.environment.vault) {
|
|
showNotificationBar = false;
|
|
} else {
|
|
// 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 = globalSettings.neverDomains;
|
|
if (!excludedDomainsDict || !(window.location.hostname in excludedDomainsDict)) {
|
|
// Set local disabled preferences
|
|
disabledAddLoginNotification = globalSettings.disableAddLoginNotification;
|
|
disabledChangedPasswordNotification = globalSettings.disableChangedPasswordNotification;
|
|
|
|
if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) {
|
|
// If the user has not disabled both notifications, then handle the initial page change (null -> actual page)
|
|
handlePageChange();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!showNotificationBar) {
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
closeExistingAndOpenBar(msg.data.type, msg.data.typeData);
|
|
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;
|
|
}
|
|
closeBar(true);
|
|
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;
|
|
}
|
|
adjustBar(msg.data);
|
|
sendResponse();
|
|
return true;
|
|
} else if (msg.command === "notificationBarPageDetails") {
|
|
// 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;
|
|
}
|
|
}
|
|
// End Message Processing
|
|
|
|
/**
|
|
* 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: 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;
|
|
}
|
|
|
|
// 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: 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)
|
|
) {
|
|
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
|
|
) {
|
|
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) {
|
|
doCollectPageDetails = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (doCollectPageDetails) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// 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();
|
|
|
|
sendPlatformMessage({
|
|
command: "checkNotificationQueue",
|
|
});
|
|
}
|
|
|
|
// This is a safeguard in case the observer misses a SPA page change.
|
|
scheduleHandlePageChange();
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
});
|
|
}
|
|
|
|
// End Page Detail Collection Methods
|
|
|
|
// 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: 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 watchedForm: WatchedForm = {
|
|
data: f,
|
|
formEl: formEl,
|
|
usernameEl: null,
|
|
passwordEl: null,
|
|
passwordEls: null,
|
|
};
|
|
// 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 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);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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"));
|
|
|
|
// 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, // Only do fallback if we have expect to find a single password field
|
|
);
|
|
} 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 (watchedForm.passwordEls.length === 0) {
|
|
watchedForm.passwordEls = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function locatePassword(
|
|
form: HTMLFormElement,
|
|
passwordData: AutofillField,
|
|
inputs: HTMLInputElement[],
|
|
doLastFallback: boolean,
|
|
): HTMLInputElement {
|
|
let el = locateField(form, passwordData, inputs);
|
|
if (el != null && el.type !== "password") {
|
|
el = null;
|
|
}
|
|
if (doLastFallback && el == null) {
|
|
el = form.querySelector('input[type="password"]');
|
|
}
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
el = form.querySelector("#" + fieldData.htmlID);
|
|
} catch {
|
|
// Ignore error, we perform fallbacks below.
|
|
}
|
|
}
|
|
if (el == null && fieldData.htmlName != null && fieldData.htmlName !== "") {
|
|
el = form.querySelector('input[name="' + fieldData.htmlName + '"]');
|
|
}
|
|
if (el == null && fieldData.elementNumber != null) {
|
|
el = inputs[fieldData.elementNumber];
|
|
}
|
|
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") {
|
|
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 = 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) {
|
|
form = modalForms[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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 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: watchedForms[i].usernameEl.value,
|
|
password: watchedForms[i].passwordEl.value,
|
|
url: document.URL,
|
|
};
|
|
|
|
// 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 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 (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 (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,
|
|
url: document.URL,
|
|
};
|
|
sendPlatformMessage({
|
|
command: "bgChangedPassword",
|
|
data: changePasswordRuntimeMessage,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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";
|
|
|
|
// 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) {
|
|
// 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(
|
|
'a, span, button[type="button"], ' + 'input[type="button"], button:not([type])',
|
|
),
|
|
) 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" &&
|
|
button.getAttribute("type") == null &&
|
|
!cancelButtonNames.has(buttonText.trim().toLowerCase())
|
|
) {
|
|
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");
|
|
if (modalForms.length === 1) {
|
|
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") {
|
|
buttonText = (button as HTMLInputElement).value;
|
|
} else {
|
|
buttonText = button.innerText;
|
|
}
|
|
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(() => {
|
|
form.dataset.bitwardenProcessed = "0";
|
|
}, 500);
|
|
}
|
|
|
|
// End Form Detection and Submission Handling
|
|
|
|
// Notification Bar Functions (open, close, height adjustment, etc.)
|
|
function closeExistingAndOpenBar(type: string, typeData: any) {
|
|
const barQueryParams = {
|
|
type,
|
|
isVaultLocked: typeData.isVaultLocked,
|
|
theme: typeData.theme,
|
|
removeIndividualVault: typeData.removeIndividualVault,
|
|
webVaultURL: typeData.webVaultURL,
|
|
};
|
|
const barQueryString = new URLSearchParams(barQueryParams).toString();
|
|
const barPage = "notification/bar.html?" + barQueryString;
|
|
|
|
const frame = document.getElementById("bit-notification-bar-iframe") as HTMLIFrameElement;
|
|
if (frame != null && frame.src.indexOf(barPage) >= 0) {
|
|
return;
|
|
}
|
|
|
|
closeBar(false);
|
|
openBar(type, barPage);
|
|
}
|
|
|
|
function openBar(type: string, barPage: string) {
|
|
barType = type;
|
|
|
|
if (document.body == null) {
|
|
return;
|
|
}
|
|
|
|
const barPageUrl: string = chrome.extension.getURL(barPage);
|
|
|
|
const iframe = document.createElement("iframe");
|
|
iframe.style.cssText = "height: 42px; width: 100%; border: 0; min-height: initial;";
|
|
iframe.id = "bit-notification-bar-iframe";
|
|
iframe.src = barPageUrl;
|
|
|
|
const frameDiv = document.createElement("div");
|
|
frameDiv.setAttribute("aria-live", "polite");
|
|
frameDiv.id = "bit-notification-bar";
|
|
frameDiv.style.cssText =
|
|
"height: 42px; width: 100%; top: 0; left: 0; padding: 0; position: fixed; " +
|
|
"z-index: 2147483647; visibility: visible;";
|
|
frameDiv.appendChild(iframe);
|
|
document.body.appendChild(frameDiv);
|
|
|
|
(iframe.contentWindow.location as any) = barPageUrl;
|
|
|
|
const spacer = document.createElement("div");
|
|
spacer.id = "bit-notification-bar-spacer";
|
|
spacer.style.cssText = "height: 42px;";
|
|
document.body.insertBefore(spacer, document.body.firstChild);
|
|
}
|
|
|
|
function closeBar(explicitClose: boolean) {
|
|
const barEl = document.getElementById("bit-notification-bar");
|
|
if (barEl != null) {
|
|
barEl.parentElement.removeChild(barEl);
|
|
}
|
|
|
|
const spacerEl = document.getElementById("bit-notification-bar-spacer");
|
|
if (spacerEl) {
|
|
spacerEl.parentElement.removeChild(spacerEl);
|
|
}
|
|
|
|
if (!explicitClose) {
|
|
return;
|
|
}
|
|
|
|
switch (barType) {
|
|
case "add":
|
|
sendPlatformMessage({
|
|
command: "bgAddClose",
|
|
});
|
|
break;
|
|
case "change":
|
|
sendPlatformMessage({
|
|
command: "bgChangeClose",
|
|
});
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
function adjustBar(data: any) {
|
|
if (data != null && data.height !== 42) {
|
|
const newHeight = data.height + "px";
|
|
doHeightAdjustment("bit-notification-bar-iframe", newHeight);
|
|
doHeightAdjustment("bit-notification-bar", newHeight);
|
|
doHeightAdjustment("bit-notification-bar-spacer", newHeight);
|
|
}
|
|
}
|
|
|
|
function doHeightAdjustment(elId: string, heightStyle: string) {
|
|
const el = document.getElementById(elId);
|
|
if (el != null) {
|
|
el.style.height = heightStyle;
|
|
}
|
|
}
|
|
// End Notification Bar Functions (open, close, height adjustment, etc.)
|
|
|
|
// 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;
|
|
}
|
|
|
|
// End Helper Functions
|
|
}
|
|
|
|
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
|
|
return new Promise((resolve) => {
|
|
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
|
|
});
|
|
}
|