diff --git a/.eslintrc b/.eslintrc index fed308d..48b8ec9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -76,7 +76,9 @@ "globals": { "_called": true, "_f": true, + "acceptedOTPFields": true, "AssociatedAction": true, + "Autocomplete": true, "browser": true, "browserAction": true, "createStylesheet": true, @@ -96,7 +98,11 @@ "kpxcBanner": true, "kpxcDefine": true, "kpxcFields": true, + "kpxcFill": true, + "kpxcForm": true, "kpxcEvent": true, + "kpxcIcons": true, + "kpxcObserverHelper": true, "kpxcPasswordDialog": true, "kpxcPasswordIcons": true, "kpxcSites": true, @@ -107,9 +113,15 @@ "kpxcUI": true, "kpxcUsernameField": true, "ManualFill": true, + "MAX_OPACITY": true, + "MIN_INPUT_FIELD_OFFSET_WIDTH": true, + "MIN_INPUT_FIELD_WIDTH_PX": true, + "MIN_OPACITY": true, + "MIN_TOTP_INPUT_LENGTH": true, "nacl": true, "page": true, "Pixels": true, + "sendMessage": true, "showNotification": true, "siteMatch": true, "slashNeededForUrl": true, diff --git a/keepassxc-browser/background/event.js b/keepassxc-browser/background/event.js index b69f8e1..8fc27f7 100755 --- a/keepassxc-browser/background/event.js +++ b/keepassxc-browser/background/event.js @@ -135,7 +135,7 @@ kpxcEvent.onCheckUpdateKeePassXC = async function() { }; kpxcEvent.onUpdateAvailableKeePassXC = async function() { - return (page.settings.checkUpdateKeePassXC != CHECK_UPDATE_NEVER) ? keepass.keePassXCUpdateAvailable() : false; + return (page.settings.checkUpdateKeePassXC !== CHECK_UPDATE_NEVER) ? keepass.keePassXCUpdateAvailable() : false; }; kpxcEvent.onRemoveCredentialsFromTabInformation = async function(tab) { diff --git a/keepassxc-browser/background/init.js b/keepassxc-browser/background/init.js index f557e32..cac4a04 100644 --- a/keepassxc-browser/background/init.js +++ b/keepassxc-browser/background/init.js @@ -147,7 +147,7 @@ browser.commands.onCommand.addListener(async (command) => { || command === 'redetect_fields' || command === 'choose_credential_fields' || command === 'retrive_credentials_forced' - || command === 'reload_extension') { + || command === 'reload_extension') { const tabs = await browser.tabs.query({ active: true, currentWindow: true }); if (tabs.length) { browser.tabs.sendMessage(tabs[0].id, { action: command }); diff --git a/keepassxc-browser/content/credential-autocomplete.js b/keepassxc-browser/content/credential-autocomplete.js index 833a445..15ded81 100644 --- a/keepassxc-browser/content/credential-autocomplete.js +++ b/keepassxc-browser/content/credential-autocomplete.js @@ -42,7 +42,7 @@ CredentialAutocomplete.prototype.fillPassword = async function(value, index, uui await sendMessage('page_set_login_id', uuid); const manualFill = await sendMessage('page_get_manual_fill'); - await kpxc.fillInCredentials(combination, value, uuid, manualFill === ManualFill.PASSWORD); + await kpxcFill.fillInCredentials(combination, value, uuid, manualFill === ManualFill.PASSWORD); }; const kpxcUserAutocomplete = new CredentialAutocomplete(); diff --git a/keepassxc-browser/content/fields.js b/keepassxc-browser/content/fields.js new file mode 100644 index 0000000..8a0f830 --- /dev/null +++ b/keepassxc-browser/content/fields.js @@ -0,0 +1,430 @@ +'use strict'; + +/** + * @Object kpxcFields + * Provides methods for input field handling. + */ +const kpxcFields = {}; + +// Returns all username & password combinations detected from the inputs. +// After username field is detected, first password field found after that will be saved as a combination. +kpxcFields.getAllCombinations = async function(inputs) { + const combinations = []; + let usernameField = null; + + for (const input of inputs) { + if (!input) { + continue; + } + + if (input.getLowerCaseAttribute('type') === 'password') { + const combination = { + username: (!usernameField || usernameField.size < 1) ? null : usernameField, + password: input, + passwordInputs: [ input ], + form: input.form + }; + + combinations.push(combination); + usernameField = null; + } else if (kpxcTOTPIcons.isValid(input)) { + // Dynamically added TOTP field + const combination = { + username: null, + password: null, + passwordInputs: [], + totp: input, + form: null + }; + + combinations.push(combination); + } else { + usernameField = input; + } + } + + if (kpxc.singleInputEnabledForPage && combinations.length === 0 && usernameField) { + const combination = { + username: usernameField, + password: null, + passwordInputs: [], + form: usernameField.form + }; + + combinations.push(combination); + } + + // Check for multiple segmented TOTP fields + if (combinations.length === 0) { + kpxcFields.getSegmentedTOTPFields(inputs, combinations); + } + + return combinations; +}; + +// Adds segmented TOTP fields to the combination if found +kpxcFields.getSegmentedTOTPFields = function(inputs, combinations) { + const addTotpFieldsToCombination = function(inputFields) { + const totpInputs = Array.from(inputFields).filter(e => e.nodeName === 'INPUT' && e.type !== 'password'); + if (totpInputs.length === 6) { + const combination = { + form: form, + totpInputs: totpInputs, + username: null, + password: null, + passwordInputs: [] + }; + + combinations.push(combination); + + // Create an icon to the right side of the segmented fields + kpxcTOTPIcons.newIcon(totpInputs[totpInputs.length - 1], kpxc.databaseState, true); + kpxcIcons.icons.push({ + field: totpInputs[totpInputs.length - 1], + iconType: kpxcIcons.iconTypes.TOTP, + segmented: true + }); + } + }; + + const form = inputs.length > 0 ? inputs[0].form : undefined; + if (form && (acceptedOTPFields.some(f => (form.className && form.className.includes(f)) + || (form.id && typeof(form.id) === 'string' && form.id.includes(f)) + || (form.name && typeof(form.name) === 'string' && form.name.includes(f)) + || form.length === 6))) { + // Use the form's elements + addTotpFieldsToCombination(form.elements); + } else if (inputs.length === 6 && inputs.every(i => (i.inputMode === 'numeric' && i.pattern.includes('0-9')) + || (i.type === 'text' && i.maxLength === 1) + || i.type === 'tel')) { + // No form is found, but input fields are possibly segmented TOTP fields + addTotpFieldsToCombination(inputs); + } + + return combinations; +}; + +// Return all input fields on the page, but ignore previously detected +kpxcFields.getAllPageInputs = async function(previousInputs = []) { + const fields = []; + const inputs = kpxcObserverHelper.getInputs(document.body); + + for (const input of inputs) { + // Ignore fields that are already detected + if (previousInputs.some(e => e === input)) { + continue; + } + + if (kpxcFields.isVisible(input) && kpxcFields.isAutocompleteAppropriate(input)) { + fields.push(input); + } + } + + kpxc.detectedFields = previousInputs.length + fields.length; + + // Show add username-only option for the site in popup + if (!kpxc.singleInputEnabledForPage + && ((fields.length === 1 && fields[0].getLowerCaseAttribute('type') !== 'password') + || (previousInputs.length === 1 && previousInputs[0].getLowerCaseAttribute('type') !== 'password'))) { + sendMessage('username_field_detected', true); + } else { + sendMessage('username_field_detected', false); + } + + await kpxc.initCombinations(inputs); + return fields; +}; + +/** + * Returns the combination where input field is used + * @param {HTMLElement} field Input field + * @param {String} givenType 'username' or 'password' + */ +kpxcFields.getCombination = async function(field, givenType) { + // If givenType is not set, return the combination that uses the selected field + for (const combination of kpxc.combinations) { + if (!givenType && Object.values(combination).find(c => c === field)) { + return combination; + } else if (givenType && combination[givenType]) { + if (combination[givenType] === field || combination[givenType].includes(field)) { + return combination; + } + } + } + + return undefined; +}; + +// Sets and returns unique ID's for the element +kpxcFields.setId = function(target) { + return [ kpxcFields.getIdFromXPath(target), kpxcFields.getIdFromProperties(target) ]; +}; + +// Returns generated unique ID's for the element. If XPath ID fails, return the fallback one. +kpxcFields.getId = function(idArray, inputField) { + if (!idArray) { + return ''; + } + + // Legacy ID is used. Convert it to the new one if possible + if (!Array.isArray(idArray) && idArray.length > 0) { + if (idArray === kpxcFields.getLegacyId(inputField)) { + idArray = kpxcFields.setId(inputField); + } + } + + const elementFromXPath = kpxcFields.getElementFromXPathId(idArray[0]); + const fallbackId = kpxcFields.getIdFromProperties(inputField); + + return elementFromXPath || (fallbackId === idArray[1] ? inputField : ''); +}; + +// Returns element XPath +kpxcFields.getIdFromXPath = function(target) { + let xpath = ''; + let pos; + let temp; + + while (target !== document.documentElement) { + pos = 0; + temp = target; + while (temp) { + if (temp.nodeType === 1 && temp.nodeName === target.nodeName) { + pos += 1; + } + + temp = temp.previousSibling; + } + + xpath = `${target.nodeName.toLowerCase()}${(pos > 1 ? `[${pos}]/` : '/')}${xpath}`; + target = target.parentNode; + } + + xpath = `/${document.documentElement.nodeName.toLowerCase()}/${xpath}`; + xpath = xpath.replace(/\/$/, ''); + return xpath; +}; + +// Generate uniqe ID from properties (new method) +kpxcFields.getIdFromProperties = function(target) { + if (target.name) { + return `${target.nodeName} ${target.type} ${target.name} ${target.placeholder}`; + } + + if (target.classList && target.classList.length > 0) { + return `${target.nodeName} ${target.type} ${target.classList.value} ${target.placeholder}`; + } + + if (target.id && target.id !== '') { + return `${target.nodeName} ${target.type} ${kpxcFields.prepareId(target.id)} ${target.placeholder}`; + } + + return `kpxc ${target.type} ${target.clientTop}${target.clientLeft}${target.clientWidth}${target.clientHeight}${target.offsetTop}${target.offsetLeft}`; +}; + +// Legacy unique ID generation for converting +kpxcFields.getLegacyId = function(target) { + if (target.classList.length > 0) { + return `${target.nodeName} ${target.type} ${target.classList.value} ${target.name} ${target.placeholder}`; + } + + if (target.id && target.id !== '') { + return `${target.nodeName} ${target.type} ${kpxcFields.prepareId(target.id)} ${target.name} ${target.placeholder}`; + } + + return `kpxc ${target.type} ${target.clientTop}${target.clientLeft}${target.clientWidth}${target.clientHeight}${target.offsetTop}${target.offsetLeft}`; +}; + +kpxcFields.getElementFromXPathId = function(xpath) { + return (new XPathEvaluator()).evaluate(xpath, document.documentElement, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; +}; + +// Check for new password via autocomplete attribute +kpxcFields.isAutocompleteAppropriate = function(field) { + const autocomplete = field.getLowerCaseAttribute('autocomplete'); + return autocomplete !== 'new-password'; +}; + +// Checks if Custom Login Fields are used for the site +kpxcFields.isCustomLoginFieldsUsed = function() { + const location = kpxc.getDocumentLocation(); + return kpxc.settings['defined-custom-fields'] !== undefined && kpxc.settings['defined-custom-fields'][location] !== undefined; +}; + +// Returns true if form is a search form +kpxcFields.isSearchForm = function(form) { + // Check form action + const formAction = form.getLowerCaseAttribute('action'); + if (formAction && (formAction.includes('search') && !formAction.includes('research'))) { + return true; + } + + // Ignore form with search classes + const formId = form.getLowerCaseAttribute('id'); + if (form.className && (form.className.includes('search') + || (formId && formId.includes('search') && !formId.includes('research')))) { + return true; + } + + return false; +}; + +// Checks if input field is a search field. Attributes or form action containing 'search', or parent element holding +// role="search" will be identified as a search field. +kpxcFields.isSearchField = function(target) { + // Check element attributes + for (const attr of target.attributes) { + if ((attr.value && (attr.value.toLowerCase().includes('search')) || attr.value === 'q')) { + return true; + } + } + + // Check closest form + const closestForm = kpxc.getForm(target); + if (closestForm && kpxcFields.isSearchForm(closestForm)) { + return true; + } + + // Check parent elements for role='search' + if (target.closest('[role~=\'search\']')) { + return true; + } + + return false; +}; + +// Returns true if element is visible on the page +kpxcFields.isVisible = function(elem) { + // Check element position and size + const rect = elem.getBoundingClientRect(); + if (rect.x < 0 + || rect.y < 0 + || rect.width < MIN_INPUT_FIELD_WIDTH_PX + || rect.x > Math.max(document.body.scrollWidth, document.body.offsetWidth, document.documentElement.clientWidth) + || rect.y > Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight) + || rect.height < MIN_INPUT_FIELD_WIDTH_PX) { + return false; + } + + // Check CSS visibility + const elemStyle = getComputedStyle(elem); + const opacity = Number(elemStyle.opacity); + if (elemStyle.visibility && (elemStyle.visibility === 'hidden' || elemStyle.visibility === 'collapse') + || (opacity < MIN_OPACITY || opacity > MAX_OPACITY) + || parseInt(elemStyle.width, 10) <= MIN_INPUT_FIELD_WIDTH_PX + || parseInt(elemStyle.height, 10) <= MIN_INPUT_FIELD_WIDTH_PX) { + return false; + } + + // Check for parent opacity + if (kpxcFields.traverseParents(elem, f => f.style.opacity === '0')) { + return false; + } + + return true; +}; + +kpxcFields.prepareId = function(id) { + return (id + '').replace(kpxcFields.rcssescape, kpxcFields.fcssescape); +}; + +/** + * Returns the first parent element satifying the {@code predicate} mapped by {@code resultFn} or else {@code defaultVal}. + * @param {HTMLElement} element The start element (excluded, starting with the parents) + * @param {function} predicate Matcher for the element to find, type (HTMLElement) => boolean + * @param {function} resultFn Callback function of type (HTMLElement) => {*} called for the first matching element + * @param {fun} defaultValFn Fallback return value supplier, if no element matching the predicate can be found + */ +kpxcFields.traverseParents = function(element, predicate, resultFn = () => true, defaultValFn = () => false) { + for (let f = element.parentElement; f !== null; f = f.parentElement) { + if (predicate(f)) { + return resultFn(f); + } + } + + return defaultValFn(); +}; + +// Use Custom Fields instead of detected combinations +kpxcFields.useCustomLoginFields = async function() { + const location = kpxc.getDocumentLocation(); + const creds = kpxc.settings['defined-custom-fields'][location]; + if (!creds.username && !creds.password && !creds.totp && creds.fields.length === 0) { + return; + } + + // Finds the input field based on the stored ID + const findInputField = async function(inputFields, idArray) { + if (idArray) { + const input = inputFields.find(e => e === kpxcFields.getId(idArray, e)); + if (input) { + return input; + } + } + + return null; + }; + + // Get all input fields from the page without any extra filters + const inputFields = []; + document.body.querySelectorAll('input, select, textarea').forEach(e => { + if (e.type !== 'hidden' && !e.disabled) { + inputFields.push(e); + } + }); + + const [ username, password, totp ] = await Promise.all([ + await findInputField(inputFields, creds.username), + await findInputField(inputFields, creds.password), + await findInputField(inputFields, creds.totp) + ]); + + // Handle StringFields + const stringFields = []; + for (const sf of creds.fields) { + const field = await findInputField(inputFields, sf); + if (field) { + stringFields.push(field); + } + } + + // Handle custom TOTP field + if (totp) { + totp.setAttribute('kpxc-defined', 'totp'); + kpxcTOTPIcons.newIcon(totp, kpxc.databaseState); + } + + // If not all expected fields are identified, return an empty combination + if ((creds.username && !username) || (creds.password && !password) || (creds.totp && !totp) + || (creds.fields.length !== stringFields.length)) { + return []; + } + + const combinations = []; + combinations.push({ + username: username, + password: password, + passwordInputs: [ password ], + totp: totp, + fields: stringFields + }); + + return combinations; +}; + +// Copied from Sizzle.js +kpxcFields.rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g; +kpxcFields.fcssescape = function(ch, asCodePoint) { + if (asCodePoint) { + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if (ch === '\0') { + return '\uFFFD'; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice(0, -1) + '\\' + ch.charCodeAt(ch.length - 1).toString(16) + ' '; + } + + // Other potentially-special ASCII characters get backslash-escaped + return '\\' + ch; +}; diff --git a/keepassxc-browser/content/fill.js b/keepassxc-browser/content/fill.js new file mode 100644 index 0000000..d9d892f --- /dev/null +++ b/keepassxc-browser/content/fill.js @@ -0,0 +1,289 @@ +'use strict'; + +/** + * @Object kpxcFill + * The class for filling credentials. + */ +const kpxcFill = {}; + +// Fill selected attribute from the context menu +kpxcFill.fillAttributeToActiveElementWith = async function(attr) { + const el = document.activeElement; + if (el.nodeName !== 'INPUT' || kpxc.credentials.length === 0) { + return; + } + + const value = Object.values(attr); + if (!value || value.length === 0) { + return; + } + + kpxc.setValue(el, value[0]); +}; + +// Fill requested from the context menu. Active element is used for combination detection +kpxcFill.fillInFromActiveElement = async function(passOnly = false) { + if (kpxc.credentials.length === 0) { + return; + } + + if (kpxc.combinations.length > 0 && kpxc.settings.autoCompleteUsernames) { + const combination = passOnly + ? kpxc.combinations.find(c => c.password) + : kpxc.combinations.find(c => c.username); + if (!combination) { + return; + } + + const field = passOnly ? combination.password : combination.username; + if (!field) { + return; + } + + // Set focus to the input field + field.focus(); + + if (kpxc.credentials.length > 1) { + // More than one credential -> show autocomplete list + kpxcUserAutocomplete.showList(field); + return; + } else { + // Just one credential -> fill the first combination found + await sendMessage('page_set_login_id', kpxc.credentials[0].uuid); + kpxcFill.fillInCredentials(combination, kpxc.credentials[0].login, kpxc.credentials[0].uuid, passOnly); + return; + } + } + + // No previous combinations detected. Create a new one from active element + const el = document.activeElement; + let combination; + if (kpxc.combinations.length === 0) { + combination = await kpxc.createCombination(el); + } else { + combination = el.type === 'password' + ? await kpxcFields.getCombination(el, 'password') + : await kpxcFields.getCombination(el, 'username'); + } + + // Do not allow filling password to a non-password field + if (passOnly && combination && !combination.password) { + kpxcUI.createNotification('warning', tr('fieldsNoPasswordField')); + return; + } + + await sendMessage('page_set_login_id', kpxc.credentials[0].uuid); + kpxcFill.fillInCredentials(combination, kpxc.credentials[0].login, kpxc.credentials[0].uuid, passOnly); +}; + +// Fill requested by Auto-Fill +kpxcFill.fillFromAutofill = async function() { + if (kpxc.credentials.length !== 1 || kpxc.combinations.length === 0) { + return; + } + + const index = kpxc.combinations.length - 1; + await sendMessage('page_set_login_id', kpxc.credentials[0].uuid); + kpxcFill.fillInCredentials(kpxc.combinations[index], kpxc.credentials[0].login, kpxc.credentials[0].uuid); + + // Generate popup-list of usernames + descriptions + sendMessage('popup_login', [ { text: `${kpxc.credentials[0].login} (${kpxc.credentials[0].name})`, uuid: kpxc.credentials[0].uuid } ]); +}; + +// Fill requested by selecting credentials from the popup +kpxcFill.fillFromPopup = async function(id, uuid) { + if (!kpxc.credentials.length === 0 || !kpxc.credentials[id] || kpxc.combinations.length === 0) { + return; + } + + await sendMessage('page_set_login_id', uuid); + const selectedCredentials = kpxc.credentials.find(c => c.uuid === uuid); + if (!selectedCredentials) { + console.log('Error: Uuid not found: ', uuid); + return; + } + + kpxcFill.fillInCredentials(kpxc.combinations[0], selectedCredentials.login, uuid); + kpxcUserAutocomplete.closeList(); +}; + +// Fill requested from TOTP icon +kpxcFill.fillFromTOTP = async function(target) { + const el = target || document.activeElement; + const credentialList = await kpxc.updateTOTPList(); + + if (credentialList && credentialList.length === 0) { + kpxcUI.createNotification('warning', tr('credentialsNoTOTPFound')); + return; + } + + if (credentialList && credentialList.length === 1) { + kpxcFill.fillTOTPFromUuid(el, credentialList[0].uuid); + return; + } + + kpxcTOTPAutocomplete.showList(el, true); +}; + +// Fill TOTP with matching uuid +kpxcFill.fillTOTPFromUuid = async function(el, uuid) { + if (!el || !uuid) { + return; + } + + const user = kpxc.credentials.find(c => c.uuid === uuid); + if (!user) { + return; + } + + if (user.totp && user.totp.length > 0) { + // Retrieve a new TOTP value + const totp = await sendMessage('get_totp', [ user.uuid, user.totp ]); + if (!totp) { + kpxcUI.createNotification('warning', tr('credentialsNoTOTPFound')); + return; + } + + kpxcFill.setTOTPValue(el, totp); + } else if (user.stringFields && user.stringFields.length > 0) { + const stringFields = user.stringFields; + for (const s of stringFields) { + const val = s['KPH: {TOTP}']; + if (val) { + kpxcFill.setTOTPValue(el, val); + } + } + } +}; + +// Set normal or segmented TOTP value +kpxcFill.setTOTPValue = function(elem, val) { + if (kpxc.combinations.length === 0) { + return; + } + + for (const comb of kpxc.combinations) { + if (comb.totpInputs && comb.totpInputs.length === 6) { + kpxcFill.fillSegmentedTotp(elem, val, comb.totpInputs); + return; + } + } + + kpxc.setValue(elem, val); +}; + +// Fill TOTP in parts +kpxcFill.fillSegmentedTotp = function(elem, val, totpInputs) { + if (!totpInputs.includes(elem)) { + return; + } + + for (let i = 0; i < 6; ++i) { + kpxc.setValue(totpInputs[i], val[i]); + } +}; + +// Fill requested from username icon +kpxcFill.fillFromUsernameIcon = async function(combination) { + await kpxc.receiveCredentialsIfNecessary(); + if (kpxc.credentials.length === 0) { + return; + } else if (kpxc.credentials.length > 1 && kpxc.settings.autoCompleteUsernames) { + kpxcUserAutocomplete.showList(combination.username || combination.password); + return; + } + + await sendMessage('page_set_login_id', kpxc.credentials[0].uuid); + kpxcFill.fillInCredentials(combination, kpxc.credentials[0].login, kpxc.credentials[0].uuid); +}; + +/** + * The main function for filling any credentials + * @param {Array} combination Combination to be used + * @param {String} predefinedUsername Predefined username. If set, there's no need to find it from combinations + * @param {Boolean} passOnly If only password is filled + * @param {String} uuid Identifier for the entry. There can be identical usernames with different password + */ +kpxcFill.fillInCredentials = async function(combination, predefinedUsername, uuid, passOnly = false) { + if (kpxc.credentials.length === 0) { + kpxcUI.createNotification('error', tr('credentialsNoLoginsFound')); + return; + } + + if (!combination || (!combination.username && !combination.password)) { + return; + } + + // Use predefined username as default + let usernameValue = predefinedUsername; + if (!usernameValue) { + // With single password field the combination.password is used instead + usernameValue = combination.username ? combination.username.value : combination.password.value; + } + + // Find the correct credentials + const selectedCredentials = kpxc.credentials.find(c => c.uuid === uuid); + if (!selectedCredentials) { + console.log('Error: Uuid not found: ', uuid); + return; + } + + // Handle auto-submit + let skipAutoSubmit = false; + if (selectedCredentials.skipAutoSubmit !== undefined) { + skipAutoSubmit = selectedCredentials.skipAutoSubmit === 'true'; + } + + // Fill password + if (combination.password) { + kpxc.setValueWithChange(combination.password, selectedCredentials.password); + await kpxc.setPasswordFilled(true); + } + + // Fill username + if (combination.username && (!combination.username.value || combination.username.value !== usernameValue)) { + if (!passOnly) { + kpxc.setValueWithChange(combination.username, usernameValue); + } + } + + // Fill StringFields + if (selectedCredentials.stringFields && selectedCredentials.stringFields.length > 0) { + kpxcFill.fillInStringFields(combination.fields, selectedCredentials.stringFields); + } + + // Close autocomplete menu after fill + kpxcUserAutocomplete.closeList(); + + // Reset ManualFill + await sendMessage('page_set_manual_fill', ManualFill.NONE); + + // Auto-submit + const autoSubmitIgnoredForSite = await kpxc.siteIgnored(IGNORE_AUTOSUBMIT); + if (kpxc.settings.autoSubmit && !skipAutoSubmit && !autoSubmitIgnoredForSite) { + const submitButton = kpxcForm.getFormSubmitButton(combination.form); + if (submitButton !== undefined) { + submitButton.click(); + } else if (combination.form) { + combination.form.submit(); + } + } else { + (combination.username || combination.password).focus(); + } +}; + +// Fills StringFields defined in Custom Fields +kpxcFill.fillInStringFields = function(fields, stringFields) { + const filledInFields = []; + if (fields && stringFields && fields.length > 0 && stringFields.length > 0) { + for (let i = 0; i < fields.length; i++) { + const currentField = fields[i]; + const stringFieldValue = Object.values(stringFields[i]); + + if (currentField && stringFieldValue[0]) { + kpxc.setValue(currentField, stringFieldValue[0]); + filledInFields.push(currentField); + } + } + } +}; diff --git a/keepassxc-browser/content/form.js b/keepassxc-browser/content/form.js new file mode 100644 index 0000000..f6102ba --- /dev/null +++ b/keepassxc-browser/content/form.js @@ -0,0 +1,183 @@ +'use strict'; + +/** + * @Object kpxcForm + * Identifies form submits and password changes. + */ +const kpxcForm = {}; +kpxcForm.formButtonQuery = 'button[type=button], button[type=submit], input[type=button], input[type=submit], button:not([type]), div[role=button]'; +kpxcForm.savedForms = []; + +// Returns true if form has been already saved +kpxcForm.formIdentified = function(form) { + return kpxcForm.savedForms.some(f => f.form === form); +}; + +// Return input fields from our Object array +kpxcForm.getCredentialFieldsFromForm = function(form) { + for (const savedForm of kpxcForm.savedForms) { + if (savedForm.form === form) { + return [ savedForm.username, savedForm.password, savedForm.passwordInputs, savedForm.totp ]; + } + } + + return []; +}; + +// Get the form submit button instead if action URL is same as the page itself +kpxcForm.getFormSubmitButton = function(form) { + if (!form || !form.action || typeof form.action !== 'string') { + return; + } + + const action = kpxc.submitUrl || form.action; + + // Check if the site needs a special handling for retrieving the form submit button + const exceptionButton = kpxcSites.formSubmitButtonExceptionFound(form); + if (exceptionButton) { + return exceptionButton; + } + + if (action.includes(document.location.origin + document.location.pathname)) { + for (const i of form.elements) { + if (i.type === 'submit') { + return i; + } + } + } + + // Try to find another button. Select the last one. + // If any formaction overriding the default action is set, ignore those buttons. + const buttons = Array.from(form.querySelectorAll(kpxcForm.formButtonQuery)).filter(b => !b.getAttribute('formAction')); + if (buttons.length > 0) { + return buttons[buttons.length - 1]; + } + + // Try to find similar buttons outside the form which are added via 'form' property + for (const e of form.elements) { + if ((e.nodeName === 'BUTTON' && (e.type === 'button' || e.type === 'submit' || e.type === '')) + || (e.nodeName === 'INPUT' && (e.type === 'button' || e.type === 'submit'))) { + return e; + } + } + + return undefined; +}; + +// Retrieve new password from a form with three elements: Current, New, Repeat New +kpxcForm.getNewPassword = function(passwordInputs = []) { + if (passwordInputs.length < 2) { + return ''; + } + + // Just two password fields, current and new + if (passwordInputs.length === 2 && passwordInputs[0] !== passwordInputs[1]) { + return passwordInputs[1].value; + } + + // Choose the last three password fields. The first ones are almost always for something else + const current = passwordInputs[passwordInputs.length - 3].value; + const newPass = passwordInputs[passwordInputs.length - 2].value; + const repeatNew = passwordInputs[passwordInputs.length - 1].value; + + if ((newPass === repeatNew && current !== newPass && current !== repeatNew) + || (current === newPass && repeatNew !== newPass && repeatNew !== current)) { + return newPass; + } + + return ''; +}; + +// Initializes form and attaches the submit button to our own callback +kpxcForm.init = function(form, credentialFields) { + if (!form.action || typeof form.action !== 'string') { + return; + } + + if (!kpxcForm.formIdentified(form) && (credentialFields.password || credentialFields.username) + || form.action.startsWith(kpxcSites.googlePasswordFormUrl)) { + kpxcForm.saveForm(form, credentialFields); + form.addEventListener('submit', kpxcForm.onSubmit); + + const submitButton = kpxcForm.getFormSubmitButton(form); + if (submitButton !== undefined) { + submitButton.addEventListener('click', kpxcForm.onSubmit); + } + } +}; + +// Triggers when form is submitted. Shows the credential banner +kpxcForm.onSubmit = async function(e) { + if (!e.isTrusted) { + return; + } + + const searchForm = f => { + if (f.nodeName === 'FORM') { + return f; + } + }; + + // Traverse parents if the form is not found. + let form = this.nodeName === 'FORM' ? this : kpxcFields.traverseParents(this, searchForm, searchForm, () => null); + + // Check for extra forms from sites.js + if (!form) { + form = kpxcSites.savedForm; + } + + // Still not found? Try using the first one from kpxcForm.savedForms + if (!form && kpxcForm.savedForms.length > 0) { + form = kpxcForm.savedForms[0].form; + } + + if (!form) { + return; + } + + const [ usernameField, passwordField, passwordInputs ] = kpxcForm.getCredentialFieldsFromForm(form); + let usernameValue = ''; + let passwordValue = ''; + + if (usernameField) { + usernameValue = usernameField.value || usernameField.placeholder; + } else if (kpxc.credentials.length === 1) { + // Single entry found for the page, use the username of it instead of an empty one + usernameValue = kpxc.credentials[0].login; + } + + // Check if the form has three password fields -> a possible password change form + if (passwordInputs && passwordInputs.length >= 2) { + passwordValue = kpxcForm.getNewPassword(passwordInputs); + } else if (passwordField) { + // Use the combination password field instead + passwordValue = passwordField.value; + } + + // Return if credentials are already found + if (kpxc.credentials.some(c => c.login === usernameValue && c.password === passwordValue)) { + return; + } + + if (passwordField) { + await kpxc.setPasswordFilled(true); + } + + const url = trimURL(kpxc.settings.saveDomainOnlyNewCreds ? window.top.location.origin : window.top.location.href); + await sendMessage('page_set_submitted', [ true, usernameValue, passwordValue, url, kpxc.credentials ]); + + // Show the banner if the page does not reload + kpxc.rememberCredentials(usernameValue, passwordValue); +}; + +// Save form to Object array +kpxcForm.saveForm = function(form, combination) { + kpxcForm.savedForms.push({ + form: form, + username: combination.username, + password: combination.password, + totp: combination.totp, + totpInputs: Array.from(form.elements).filter(e => e.nodeName === 'INPUT' && kpxcTOTPIcons.isValid(e)), + passwordInputs: Array.from(form.elements).filter(e => e.nodeName === 'INPUT' && e.type === 'password') + }); +}; diff --git a/keepassxc-browser/content/icons.js b/keepassxc-browser/content/icons.js new file mode 100644 index 0000000..99144b4 --- /dev/null +++ b/keepassxc-browser/content/icons.js @@ -0,0 +1,115 @@ +'use strict'; + +/** + * @Object kpxcIcons + * Icon handling. + */ +const kpxcIcons = {}; +kpxcIcons.icons = []; +kpxcIcons.iconTypes = { USERNAME: 0, PASSWORD: 1, TOTP: 2 }; + +// Adds an icon to input field +kpxcIcons.addIcon = async function(field, iconType) { + if (!field || iconType < 0 || iconType > 2) { + return; + } + + let iconSet = false; + if (iconType === kpxcIcons.iconTypes.USERNAME && kpxcUsernameIcons.isValid(field)) { + kpxcUsernameIcons.newIcon(field, kpxc.databaseState); + iconSet = true; + } else if (iconType === kpxcIcons.iconTypes.PASSWORD && kpxcPasswordIcons.isValid(field)) { + kpxcPasswordIcons.newIcon(field, kpxc.databaseState); + iconSet = true; + } else if (iconType === kpxcIcons.iconTypes.TOTP && kpxcTOTPIcons.isValid(field)) { + kpxcTOTPIcons.newIcon(field, kpxc.databaseState); + iconSet = true; + } + + if (iconSet) { + kpxcIcons.icons.push({ + field: field, + iconType: iconType + }); + } +}; + +// Adds all necessary icons to a saved form +kpxcIcons.addIconsFromForm = async function(form) { + const addUsernameIcons = async function(c) { + if (kpxc.settings.showLoginFormIcon && await kpxc.passwordFilledWithExceptions(c) === false) { + // Special case where everything else has been hidden, but a single password field is now displayed. + // For example PayPal and Amazon is handled like this. + if (c.username && !c.password && c.passwordInputs.length === 1) { + kpxcIcons.addIcon(c.passwordInputs[0], kpxcIcons.iconTypes.USERNAME); + } + + if (c.username && !c.username.readOnly) { + kpxcIcons.addIcon(c.username, kpxcIcons.iconTypes.USERNAME); + } else if (c.password && (!c.username || (c.username && c.username.readOnly))) { + // Single password field + kpxcIcons.addIcon(c.password, kpxcIcons.iconTypes.USERNAME); + } + } + }; + + const addPasswordIcons = async function(c) { + // Show password icons also with forms without any username field + if (kpxc.settings.usePasswordGeneratorIcons + && ((c.username && c.password) || (!c.username && c.passwordInputs.length > 0))) { + for (const input of c.passwordInputs) { + kpxcIcons.addIcon(input, kpxcIcons.iconTypes.PASSWORD); + } + } + }; + + const addTOTPIcons = async function(c) { + if (c.totp && kpxc.settings.showOTPIcon) { + kpxcIcons.addIcon(c.totp, kpxcIcons.iconTypes.TOTP); + } + }; + + await Promise.all([ + await addUsernameIcons(form), + await addPasswordIcons(form), + await addTOTPIcons(form) + ]); +}; + +// Delete all icons that have been hidden from the page view +kpxcIcons.deleteHiddenIcons = function() { + kpxcUsernameIcons.deleteHiddenIcons(); + kpxcPasswordIcons.deleteHiddenIcons(); + kpxcTOTPIcons.deleteHiddenIcons(); +}; + +// Initializes all icons needed to be shown +kpxcIcons.initIcons = async function(combinations = []) { + if (combinations.length === 0) { + return; + } + + for (const form of kpxcForm.savedForms) { + await kpxcIcons.addIconsFromForm(form); + } + + // Check for other combinations that are not in any form + for (const c of combinations) { + if (c.form) { + continue; + } + + await kpxcIcons.addIconsFromForm(c); + } +}; + +kpxcIcons.hasIcon = function(field) { + return !field ? false : kpxcIcons.icons.some(i => i.field === field); +}; + +// Sets the icons to corresponding database lock status +kpxcIcons.switchIcons = function() { + kpxcUsernameIcons.switchIcon(kpxc.databaseState); + kpxcPasswordIcons.switchIcon(kpxc.databaseState); + kpxcTOTPIcons.switchIcon(kpxc.databaseState); +}; diff --git a/keepassxc-browser/content/keepassxc-browser.js b/keepassxc-browser/content/keepassxc-browser.js index 5d8b90c..aad39bb 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -1,8 +1,5 @@ 'use strict'; -const _maximumInputs = 100; -const _maximumMutations = 200; - // Contains already called method names const _called = {}; _called.automaticRedetectCompleted = false; @@ -13,733 +10,6 @@ const sendMessage = async function(action, args) { return await browser.runtime.sendMessage({ action: action, args: args }); }; -/** - * @Object kpxcIcons - * Icon handling. - */ -const kpxcIcons = {}; -kpxcIcons.icons = []; -kpxcIcons.iconTypes = { USERNAME: 0, PASSWORD: 1, TOTP: 2 }; - -// Adds an icon to input field -kpxcIcons.addIcon = async function(field, iconType) { - if (!field || iconType < 0 || iconType > 2) { - return; - } - - let iconSet = false; - if (iconType === kpxcIcons.iconTypes.USERNAME && kpxcUsernameIcons.isValid(field)) { - kpxcUsernameIcons.newIcon(field, kpxc.databaseState); - iconSet = true; - } else if (iconType === kpxcIcons.iconTypes.PASSWORD && kpxcPasswordIcons.isValid(field)) { - kpxcPasswordIcons.newIcon(field, kpxc.databaseState); - iconSet = true; - } else if (iconType === kpxcIcons.iconTypes.TOTP && kpxcTOTPIcons.isValid(field)) { - kpxcTOTPIcons.newIcon(field, kpxc.databaseState); - iconSet = true; - } - - if (iconSet) { - kpxcIcons.icons.push({ - field: field, - iconType: iconType - }); - } -}; - -// Adds all necessary icons to a saved form -kpxcIcons.addIconsFromForm = async function(form) { - const addUsernameIcons = async function(c) { - if (kpxc.settings.showLoginFormIcon && await kpxc.passwordFilledWithExceptions(c) === false) { - // Special case where everything else has been hidden, but a single password field is now displayed. - // For example PayPal and Amazon is handled like this. - if (c.username && !c.password && c.passwordInputs.length === 1) { - kpxcIcons.addIcon(c.passwordInputs[0], kpxcIcons.iconTypes.USERNAME); - } - - if (c.username && !c.username.readOnly) { - kpxcIcons.addIcon(c.username, kpxcIcons.iconTypes.USERNAME); - } else if (c.password && (!c.username || (c.username && c.username.readOnly))) { - // Single password field - kpxcIcons.addIcon(c.password, kpxcIcons.iconTypes.USERNAME); - } - } - }; - - const addPasswordIcons = async function(c) { - // Show password icons also with forms without any username field - if (kpxc.settings.usePasswordGeneratorIcons - && ((c.username && c.password) || (!c.username && c.passwordInputs.length > 0))) { - for (const input of c.passwordInputs) { - kpxcIcons.addIcon(input, kpxcIcons.iconTypes.PASSWORD); - } - } - }; - - const addTOTPIcons = async function(c) { - if (c.totp && kpxc.settings.showOTPIcon) { - kpxcIcons.addIcon(c.totp, kpxcIcons.iconTypes.TOTP); - } - }; - - await Promise.all([ - await addUsernameIcons(form), - await addPasswordIcons(form), - await addTOTPIcons(form) - ]); -}; - -// Delete all icons that have been hidden from the page view -kpxcIcons.deleteHiddenIcons = function() { - kpxcUsernameIcons.deleteHiddenIcons(); - kpxcPasswordIcons.deleteHiddenIcons(); - kpxcTOTPIcons.deleteHiddenIcons(); -}; - -// Initializes all icons needed to be shown -kpxcIcons.initIcons = async function(combinations = []) { - if (combinations.length === 0) { - return; - } - - for (const form of kpxcForm.savedForms) { - await kpxcIcons.addIconsFromForm(form); - } - - // Check for other combinations that are not in any form - for (const c of combinations) { - if (c.form) { - continue; - } - - await kpxcIcons.addIconsFromForm(c); - } -}; - -kpxcIcons.hasIcon = function(field) { - return !field ? false : kpxcIcons.icons.some(i => i.field === field); -}; - -// Sets the icons to corresponding database lock status -kpxcIcons.switchIcons = function() { - kpxcUsernameIcons.switchIcon(kpxc.databaseState); - kpxcPasswordIcons.switchIcon(kpxc.databaseState); - kpxcTOTPIcons.switchIcon(kpxc.databaseState); -}; - - -/** - * @Object kpxcForm - * Identifies form submits and password changes. - */ -const kpxcForm = {}; -kpxcForm.formButtonQuery = 'button[type=button], button[type=submit], input[type=button], input[type=submit], button:not([type]), div[role=button]'; -kpxcForm.savedForms = []; - -// Returns true if form has been already saved -kpxcForm.formIdentified = function(form) { - return kpxcForm.savedForms.some(f => f.form === form); -}; - -// Return input fields from our Object array -kpxcForm.getCredentialFieldsFromForm = function(form) { - for (const savedForm of kpxcForm.savedForms) { - if (savedForm.form === form) { - return [ savedForm.username, savedForm.password, savedForm.passwordInputs, savedForm.totp ]; - } - } - - return []; -}; - -// Get the form submit button instead if action URL is same as the page itself -kpxcForm.getFormSubmitButton = function(form) { - if (!form || !form.action || typeof form.action !== 'string') { - return; - } - - const action = kpxc.submitUrl || form.action; - - // Check if the site needs a special handling for retrieving the form submit button - const exceptionButton = kpxcSites.formSubmitButtonExceptionFound(form); - if (exceptionButton) { - return exceptionButton; - } - - if (action.includes(document.location.origin + document.location.pathname)) { - for (const i of form.elements) { - if (i.type === 'submit') { - return i; - } - } - } - - // Try to find another button. Select the last one. - // If any formaction overriding the default action is set, ignore those buttons. - const buttons = Array.from(form.querySelectorAll(kpxcForm.formButtonQuery)).filter(b => !b.getAttribute('formAction')); - if (buttons.length > 0) { - return buttons[buttons.length - 1]; - } - - // Try to find similar buttons outside the form which are added via 'form' property - for (const e of form.elements) { - if ((e.nodeName === 'BUTTON' && (e.type === 'button' || e.type === 'submit' || e.type === '')) - || (e.nodeName === 'INPUT' && (e.type === 'button' || e.type === 'submit'))) { - return e; - } - } - - return undefined; -}; - -// Retrieve new password from a form with three elements: Current, New, Repeat New -kpxcForm.getNewPassword = function(passwordInputs = []) { - if (passwordInputs.length < 2) { - return ''; - } - - // Just two password fields, current and new - if (passwordInputs.length === 2 && passwordInputs[0] !== passwordInputs[1]) { - return passwordInputs[1].value; - } - - // Choose the last three password fields. The first ones are almost always for something else - const current = passwordInputs[passwordInputs.length - 3].value; - const newPass = passwordInputs[passwordInputs.length - 2].value; - const repeatNew = passwordInputs[passwordInputs.length - 1].value; - - if ((newPass === repeatNew && current !== newPass && current !== repeatNew) - || (current === newPass && repeatNew !== newPass && repeatNew !== current)) { - return newPass; - } - - return ''; -}; - -// Initializes form and attaches the submit button to our own callback -kpxcForm.init = function(form, credentialFields) { - if (!form.action || typeof form.action !== 'string') { - return; - } - - if (!kpxcForm.formIdentified(form) && (credentialFields.password || credentialFields.username) - || form.action.startsWith(kpxcSites.googlePasswordFormUrl)) { - kpxcForm.saveForm(form, credentialFields); - form.addEventListener('submit', kpxcForm.onSubmit); - - const submitButton = kpxcForm.getFormSubmitButton(form); - if (submitButton !== undefined) { - submitButton.addEventListener('click', kpxcForm.onSubmit); - } - } -}; - -// Triggers when form is submitted. Shows the credential banner -kpxcForm.onSubmit = async function(e) { - if (!e.isTrusted) { - return; - } - - const searchForm = f => { - if (f.nodeName === 'FORM') { - return f; - } - }; - - // Traverse parents if the form is not found. - let form = this.nodeName === 'FORM' ? this : kpxcFields.traverseParents(this, searchForm, searchForm, () => null); - - // Check for extra forms from sites.js - if (!form) { - form = kpxcSites.savedForm; - } - - // Still not found? Try using the first one from kpxcForm.savedForms - if (!form && kpxcForm.savedForms.length > 0) { - form = kpxcForm.savedForms[0].form; - } - - if (!form) { - return; - } - - const [ usernameField, passwordField, passwordInputs ] = kpxcForm.getCredentialFieldsFromForm(form); - let usernameValue = ''; - let passwordValue = ''; - - if (usernameField) { - usernameValue = usernameField.value || usernameField.placeholder; - } else if (kpxc.credentials.length === 1) { - // Single entry found for the page, use the username of it instead of an empty one - usernameValue = kpxc.credentials[0].login; - } - - // Check if the form has three password fields -> a possible password change form - if (passwordInputs && passwordInputs.length >= 2) { - passwordValue = kpxcForm.getNewPassword(passwordInputs); - } else if (passwordField) { - // Use the combination password field instead - passwordValue = passwordField.value; - } - - // Return if credentials are already found - if (kpxc.credentials.some(c => c.login === usernameValue && c.password === passwordValue)) { - return; - } - - if (passwordField) { - await kpxc.setPasswordFilled(true); - } - - const url = trimURL(kpxc.settings.saveDomainOnlyNewCreds ? window.top.location.origin : window.top.location.href); - await sendMessage('page_set_submitted', [ true, usernameValue, passwordValue, url, kpxc.credentials ]); - - // Show the banner if the page does not reload - kpxc.rememberCredentials(usernameValue, passwordValue); -}; - -// Save form to Object array -kpxcForm.saveForm = function(form, combination) { - kpxcForm.savedForms.push({ - form: form, - username: combination.username, - password: combination.password, - totp: combination.totp, - totpInputs: Array.from(form.elements).filter(e => e.nodeName === 'INPUT' && kpxcTOTPIcons.isValid(e)), - passwordInputs: Array.from(form.elements).filter(e => e.nodeName === 'INPUT' && e.type === 'password') - }); -}; - - -/** - * @Object kpxcFields - * Provides methods for input field handling. - */ -const kpxcFields = {}; - -// Returns all username & password combinations detected from the inputs. -// After username field is detected, first password field found after that will be saved as a combination. -kpxcFields.getAllCombinations = async function(inputs) { - const combinations = []; - let usernameField = null; - - for (const input of inputs) { - if (!input) { - continue; - } - - if (input.getLowerCaseAttribute('type') === 'password') { - const combination = { - username: (!usernameField || usernameField.size < 1) ? null : usernameField, - password: input, - passwordInputs: [ input ], - form: input.form - }; - - combinations.push(combination); - usernameField = null; - } else if (kpxcTOTPIcons.isValid(input)) { - // Dynamically added TOTP field - const combination = { - username: null, - password: null, - passwordInputs: [], - totp: input, - form: null - }; - - combinations.push(combination); - } else { - usernameField = input; - } - } - - if (kpxc.singleInputEnabledForPage && combinations.length === 0 && usernameField) { - const combination = { - username: usernameField, - password: null, - passwordInputs: [], - form: usernameField.form - }; - - combinations.push(combination); - } - - // Check for multiple segmented TOTP fields - if (combinations.length === 0) { - kpxcFields.getSegmentedTOTPFields(inputs, combinations); - } - - return combinations; -}; - -// Adds segmented TOTP fields to the combination if found -kpxcFields.getSegmentedTOTPFields = function(inputs, combinations) { - const addTotpFieldsToCombination = function(inputFields) { - const totpInputs = Array.from(inputFields).filter(e => e.nodeName === 'INPUT' && e.type !== 'password'); - if (totpInputs.length === 6) { - const combination = { - form: form, - totpInputs: totpInputs, - username: null, - password: null, - passwordInputs: [] - }; - - combinations.push(combination); - - // Create an icon to the right side of the segmented fields - kpxcTOTPIcons.newIcon(totpInputs[totpInputs.length - 1], kpxc.databaseState, true); - kpxcIcons.icons.push({ - field: totpInputs[totpInputs.length - 1], - iconType: kpxcIcons.iconTypes.TOTP, - segmented: true - }); - } - }; - - const form = inputs.length > 0 ? inputs[0].form : undefined; - if (form && (acceptedOTPFields.some(f => (form.className && form.className.includes(f)) - || (form.id && typeof(form.id) === 'string' && form.id.includes(f)) - || (form.name && typeof(form.name) === 'string' && form.name.includes(f)) - || form.length === 6))) { - // Use the form's elements - addTotpFieldsToCombination(form.elements); - } else if (inputs.length === 6 && inputs.every(i => (i.inputMode === 'numeric' && i.pattern.includes('0-9')) - || (i.type === 'text' && i.maxLength == 1) - || i.type === 'tel')) { - // No form is found, but input fields are possibly segmented TOTP fields - addTotpFieldsToCombination(inputs); - } - - return combinations; -}; - -// Return all input fields on the page, but ignore previously detected -kpxcFields.getAllPageInputs = async function(previousInputs = []) { - const fields = []; - const inputs = kpxcObserverHelper.getInputs(document.body); - - for (const input of inputs) { - // Ignore fields that are already detected - if (previousInputs.some(e => e === input)) { - continue; - } - - if (kpxcFields.isVisible(input) && kpxcFields.isAutocompleteAppropriate(input)) { - fields.push(input); - } - } - - kpxc.detectedFields = previousInputs.length + fields.length; - - // Show add username-only option for the site in popup - if (!kpxc.singleInputEnabledForPage - && ((fields.length === 1 && fields[0].getLowerCaseAttribute('type') !== 'password') - || (previousInputs.length === 1 && previousInputs[0].getLowerCaseAttribute('type') !== 'password'))) { - sendMessage('username_field_detected', true); - } else { - sendMessage('username_field_detected', false); - } - - await kpxc.initCombinations(inputs); - return fields; -}; - -/** - * Returns the combination where input field is used - * @param {HTMLElement} field Input field - * @param {String} givenType 'username' or 'password' - */ -kpxcFields.getCombination = async function(field, givenType) { - // If givenType is not set, return the combination that uses the selected field - for (const combination of kpxc.combinations) { - if (!givenType && Object.values(combination).find(c => c === field)) { - return combination; - } else if (givenType && combination[givenType]) { - if (combination[givenType] === field || combination[givenType].includes(field)) { - return combination; - } - } - } - - return undefined; -}; - -// Sets and returns unique ID's for the element -kpxcFields.setId = function(target) { - return [ kpxcFields.getIdFromXPath(target), kpxcFields.getIdFromProperties(target) ]; -}; - -// Returns generated unique ID's for the element. If XPath ID fails, return the fallback one. -kpxcFields.getId = function(idArray, inputField) { - if (!idArray) { - return ''; - } - - // Legacy ID is used. Convert it to the new one if possible - if (!Array.isArray(idArray) && idArray.length > 0) { - if (idArray === kpxcFields.getLegacyId(inputField)) { - idArray = kpxcFields.setId(inputField); - } - } - - const elementFromXPath = kpxcFields.getElementFromXPathId(idArray[0]); - const fallbackId = kpxcFields.getIdFromProperties(inputField); - - return elementFromXPath || (fallbackId === idArray[1] ? inputField : ''); -}; - -// Returns element XPath -kpxcFields.getIdFromXPath = function(target) { - let xpath = ''; - let pos; - let temp; - - while (target !== document.documentElement) { - pos = 0; - temp = target; - while (temp) { - if (temp.nodeType === 1 && temp.nodeName === target.nodeName) { - pos += 1; - } - - temp = temp.previousSibling; - } - - xpath = `${target.nodeName.toLowerCase()}${(pos > 1 ? `[${pos}]/` : '/')}${xpath}`; - target = target.parentNode; - } - - xpath = `/${document.documentElement.nodeName.toLowerCase()}/${xpath}`; - xpath = xpath.replace(/\/$/, ''); - return xpath; -}; - -// Generate uniqe ID from properties (new method) -kpxcFields.getIdFromProperties = function(target) { - if (target.name) { - return `${target.nodeName} ${target.type} ${target.name} ${target.placeholder}`; - } - - if (target.classList && target.classList.length > 0) { - return `${target.nodeName} ${target.type} ${target.classList.value} ${target.placeholder}`; - } - - if (target.id && target.id !== '') { - return `${target.nodeName} ${target.type} ${kpxcFields.prepareId(target.id)} ${target.placeholder}`; - } - - return `kpxc ${target.type} ${target.clientTop}${target.clientLeft}${target.clientWidth}${target.clientHeight}${target.offsetTop}${target.offsetLeft}`; -}; - -// Legacy unique ID generation for converting -kpxcFields.getLegacyId = function(target) { - if (target.classList.length > 0) { - return `${target.nodeName} ${target.type} ${target.classList.value} ${target.name} ${target.placeholder}`; - } - - if (target.id && target.id !== '') { - return `${target.nodeName} ${target.type} ${kpxcFields.prepareId(target.id)} ${target.name} ${target.placeholder}`; - } - - return `kpxc ${target.type} ${target.clientTop}${target.clientLeft}${target.clientWidth}${target.clientHeight}${target.offsetTop}${target.offsetLeft}`; -}; - -kpxcFields.getElementFromXPathId = function(xpath) { - return (new XPathEvaluator()).evaluate(xpath, document.documentElement, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; -}; - -// Check for new password via autocomplete attribute -kpxcFields.isAutocompleteAppropriate = function(field) { - const autocomplete = field.getLowerCaseAttribute('autocomplete'); - return autocomplete !== 'new-password'; -}; - -// Checks if Custom Login Fields are used for the site -kpxcFields.isCustomLoginFieldsUsed = function() { - const location = kpxc.getDocumentLocation(); - return kpxc.settings['defined-custom-fields'] !== undefined && kpxc.settings['defined-custom-fields'][location] !== undefined; -}; - -// Returns true if form is a search form -kpxcFields.isSearchForm = function(form) { - // Check form action - const formAction = form.getLowerCaseAttribute('action'); - if (formAction && (formAction.includes('search') && !formAction.includes('research'))) { - return true; - } - - // Ignore form with search classes - const formId = form.getLowerCaseAttribute('id'); - if (form.className && (form.className.includes('search') - || (formId && formId.includes('search') && !formId.includes('research')))) { - return true; - } - - return false; -}; - -// Checks if input field is a search field. Attributes or form action containing 'search', or parent element holding -// role="search" will be identified as a search field. -kpxcFields.isSearchField = function(target) { - // Check element attributes - for (const attr of target.attributes) { - if ((attr.value && (attr.value.toLowerCase().includes('search')) || attr.value === 'q')) { - return true; - } - } - - // Check closest form - const closestForm = kpxc.getForm(target); - if (closestForm && kpxcFields.isSearchForm(closestForm)) { - return true; - } - - // Check parent elements for role='search' - if (target.closest('[role~=\'search\']')) { - return true; - } - - return false; -}; - -// Returns true if element is visible on the page -kpxcFields.isVisible = function(elem) { - // Check element position and size - const rect = elem.getBoundingClientRect(); - if (rect.x < 0 - || rect.y < 0 - || rect.width < MIN_INPUT_FIELD_WIDTH_PX - || rect.x > Math.max(document.body.scrollWidth, document.body.offsetWidth, document.documentElement.clientWidth) - || rect.y > Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight) - || rect.height < MIN_INPUT_FIELD_WIDTH_PX) { - return false; - } - - // Check CSS visibility - const elemStyle = getComputedStyle(elem); - const opacity = Number(elemStyle.opacity); - if (elemStyle.visibility && (elemStyle.visibility === 'hidden' || elemStyle.visibility === 'collapse') - || (opacity < MIN_OPACITY || opacity > MAX_OPACITY) - || parseInt(elemStyle.width, 10) <= MIN_INPUT_FIELD_WIDTH_PX - || parseInt(elemStyle.height, 10) <= MIN_INPUT_FIELD_WIDTH_PX) { - return false; - } - - // Check for parent opacity - if (kpxcFields.traverseParents(elem, f => f.style.opacity === '0')) { - return false; - } - - return true; -}; - -kpxcFields.prepareId = function(id) { - return (id + '').replace(kpxcFields.rcssescape, kpxcFields.fcssescape); -}; - -/** - * Returns the first parent element satifying the {@code predicate} mapped by {@code resultFn} or else {@code defaultVal}. - * @param {HTMLElement} element The start element (excluded, starting with the parents) - * @param {function} predicate Matcher for the element to find, type (HTMLElement) => boolean - * @param {function} resultFn Callback function of type (HTMLElement) => {*} called for the first matching element - * @param {fun} defaultValFn Fallback return value supplier, if no element matching the predicate can be found - */ -kpxcFields.traverseParents = function(element, predicate, resultFn = () => true, defaultValFn = () => false) { - for (let f = element.parentElement; f !== null; f = f.parentElement) { - if (predicate(f)) { - return resultFn(f); - } - } - - return defaultValFn(); -}; - -// Use Custom Fields instead of detected combinations -kpxcFields.useCustomLoginFields = async function() { - const location = kpxc.getDocumentLocation(); - const creds = kpxc.settings['defined-custom-fields'][location]; - if (!creds.username && !creds.password && !creds.totp && creds.fields.length === 0) { - return; - } - - // Finds the input field based on the stored ID - const findInputField = async function(inputFields, idArray) { - if (idArray) { - const input = inputFields.find(e => e === kpxcFields.getId(idArray, e)); - if (input) { - return input; - } - } - - return null; - }; - - // Get all input fields from the page without any extra filters - const inputFields = []; - document.body.querySelectorAll('input, select, textarea').forEach(e => { - if (e.type !== 'hidden' && !e.disabled) { - inputFields.push(e); - } - }); - - const [ username, password, totp ] = await Promise.all([ - await findInputField(inputFields, creds.username), - await findInputField(inputFields, creds.password), - await findInputField(inputFields, creds.totp) - ]); - - // Handle StringFields - const stringFields = []; - for (const sf of creds.fields) { - const field = await findInputField(inputFields, sf); - if (field) { - stringFields.push(field); - } - } - - // Handle custom TOTP field - if (totp) { - totp.setAttribute('kpxc-defined', 'totp'); - kpxcTOTPIcons.newIcon(totp, kpxc.databaseState); - } - - // If not all expected fields are identified, return an empty combination - if ((creds.username && !username) || (creds.password && !password) || (creds.totp && !totp) - || (creds.fields.length !== stringFields.length)) { - return []; - } - - const combinations = []; - combinations.push({ - username: username, - password: password, - passwordInputs: [ password ], - totp: totp, - fields: stringFields - }); - - return combinations; -}; - -// Copied from Sizzle.js -kpxcFields.rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g; -kpxcFields.fcssescape = function(ch, asCodePoint) { - if (asCodePoint) { - // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER - if (ch === '\0') { - return '\uFFFD'; - } - - // Control characters and (dependent upon position) numbers get escaped as code points - return ch.slice(0, -1) + '\\' + ch.charCodeAt(ch.length - 1).toString(16) + ' '; - } - - // Other potentially-special ASCII characters get backslash-escaped - return '\\' + ch; -}; - /** * @Object kpxc @@ -839,7 +109,7 @@ kpxc.detectDatabaseChange = async function(response) { // is handled here when the opened database has been regognized. It's not a pretty hack. const manualFill = await sendMessage('page_get_manual_fill'); if (manualFill !== ManualFill.NONE) { - await kpxc.fillInFromActiveElement(manualFill === ManualFill.PASSWORD); + await kpxcFill.fillInFromActiveElement(manualFill === ManualFill.PASSWORD); await sendMessage('page_set_manual_fill', ManualFill.NONE); } } else if (!response.connected) { @@ -849,288 +119,6 @@ kpxc.detectDatabaseChange = async function(response) { } }; -// Fill selected attribute from the context menu -kpxc.fillAttributeToActiveElementWith = async function(attr) { - const el = document.activeElement; - if (el.nodeName !== 'INPUT' || kpxc.credentials.length === 0) { - return; - } - - const value = Object.values(attr); - if (!value || value.length === 0) { - return; - } - - kpxc.setValue(el, value[0]); -}; - -// Fill requested from the context menu. Active element is used for combination detection -kpxc.fillInFromActiveElement = async function(passOnly = false) { - if (kpxc.credentials.length === 0) { - return; - } - - if (kpxc.combinations.length > 0 && kpxc.settings.autoCompleteUsernames) { - const combination = passOnly - ? kpxc.combinations.find(c => c.password) - : kpxc.combinations.find(c => c.username); - if (!combination) { - return; - } - - const field = passOnly ? combination.password : combination.username; - if (!field) { - return; - } - - // Set focus to the input field - field.focus(); - - if (kpxc.credentials.length > 1) { - // More than one credential -> show autocomplete list - kpxcUserAutocomplete.showList(field); - return; - } else { - // Just one credential -> fill the first combination found - await sendMessage('page_set_login_id', kpxc.credentials[0].uuid); - kpxc.fillInCredentials(combination, kpxc.credentials[0].login, kpxc.credentials[0].uuid, passOnly); - return; - } - } - - // No previous combinations detected. Create a new one from active element - const el = document.activeElement; - let combination; - if (kpxc.combinations.length === 0) { - combination = await kpxc.createCombination(el); - } else { - combination = el.type === 'password' - ? await kpxcFields.getCombination(el, 'password') - : await kpxcFields.getCombination(el, 'username'); - } - - // Do not allow filling password to a non-password field - if (passOnly && combination && !combination.password) { - kpxcUI.createNotification('warning', tr('fieldsNoPasswordField')); - return; - } - - await sendMessage('page_set_login_id', kpxc.credentials[0].uuid); - kpxc.fillInCredentials(combination, kpxc.credentials[0].login, kpxc.credentials[0].uuid, passOnly); -}; - -// Fill requested by Auto-Fill -kpxc.fillFromAutofill = async function() { - if (kpxc.credentials.length !== 1 || kpxc.combinations.length === 0) { - return; - } - - const index = kpxc.combinations.length - 1; - await sendMessage('page_set_login_id', kpxc.credentials[0].uuid); - kpxc.fillInCredentials(kpxc.combinations[index], kpxc.credentials[0].login, kpxc.credentials[0].uuid); - - // Generate popup-list of usernames + descriptions - sendMessage('popup_login', [ { text: `${kpxc.credentials[0].login} (${kpxc.credentials[0].name})`, uuid: kpxc.credentials[0].uuid } ]); -}; - -// Fill requested by selecting credentials from the popup -kpxc.fillFromPopup = async function(id, uuid) { - if (!kpxc.credentials.length === 0 || !kpxc.credentials[id] || kpxc.combinations.length === 0) { - return; - } - - await sendMessage('page_set_login_id', uuid); - const selectedCredentials = kpxc.credentials.find(c => c.uuid === uuid); - if (!selectedCredentials) { - console.log('Error: Uuid not found: ', uuid); - return; - } - - kpxc.fillInCredentials(kpxc.combinations[0], selectedCredentials.login, uuid); - kpxcUserAutocomplete.closeList(); -}; - -// Fill requested from TOTP icon -kpxc.fillFromTOTP = async function(target) { - const el = target || document.activeElement; - const credentialList = await kpxc.updateTOTPList(); - - if (credentialList && credentialList.length === 0) { - kpxcUI.createNotification('warning', tr('credentialsNoTOTPFound')); - return; - } - - if (credentialList && credentialList.length === 1) { - kpxc.fillTOTPFromUuid(el, credentialList[0].uuid); - return; - } - - kpxcTOTPAutocomplete.showList(el, true); -}; - -// Fill TOTP with matching uuid -kpxc.fillTOTPFromUuid = async function(el, uuid) { - if (!el || !uuid) { - return; - } - - const user = kpxc.credentials.find(c => c.uuid === uuid); - if (!user) { - return; - } - - if (user.totp && user.totp.length > 0) { - // Retrieve a new TOTP value - const totp = await sendMessage('get_totp', [ user.uuid, user.totp ]); - if (!totp) { - kpxcUI.createNotification('warning', tr('credentialsNoTOTPFound')); - return; - } - - kpxc.setTOTPValue(el, totp); - } else if (user.stringFields && user.stringFields.length > 0) { - const stringFields = user.stringFields; - for (const s of stringFields) { - const val = s['KPH: {TOTP}']; - if (val) { - kpxc.setTOTPValue(el, val); - } - } - } -}; - -// Set normal or segmented TOTP value -kpxc.setTOTPValue = function(elem, val) { - if (kpxc.combinations.length === 0) { - return; - } - - for (const comb of kpxc.combinations) { - if (comb.totpInputs && comb.totpInputs.length === 6) { - kpxc.fillSegmentedTotp(elem, val, comb.totpInputs); - return; - } - } - - kpxc.setValue(elem, val); -}; - -// Fill TOTP in parts -kpxc.fillSegmentedTotp = function(elem, val, totpInputs) { - if (!totpInputs.includes(elem)) { - return; - } - - for (let i = 0; i < 6; ++i) { - kpxc.setValue(totpInputs[i], val[i]); - } -}; - -// Fill requested from username icon -kpxc.fillFromUsernameIcon = async function(combination) { - await kpxc.receiveCredentialsIfNecessary(); - if (kpxc.credentials.length === 0) { - return; - } else if (kpxc.credentials.length > 1 && kpxc.settings.autoCompleteUsernames) { - kpxcUserAutocomplete.showList(combination.username || combination.password); - return; - } - - await sendMessage('page_set_login_id', kpxc.credentials[0].uuid); - kpxc.fillInCredentials(combination, kpxc.credentials[0].login, kpxc.credentials[0].uuid); -}; - -/** - * The main function for filling any credentials - * @param {Array} combination Combination to be used - * @param {String} predefinedUsername Predefined username. If set, there's no need to find it from combinations - * @param {Boolean} passOnly If only password is filled - * @param {String} uuid Identifier for the entry. There can be identical usernames with different password - */ -kpxc.fillInCredentials = async function(combination, predefinedUsername, uuid, passOnly = false) { - if (kpxc.credentials.length === 0) { - kpxcUI.createNotification('error', tr('credentialsNoLoginsFound')); - return; - } - - if (!combination || (!combination.username && !combination.password)) { - return; - } - - // Use predefined username as default - let usernameValue = predefinedUsername; - if (!usernameValue) { - // With single password field the combination.password is used instead - usernameValue = combination.username ? combination.username.value : combination.password.value; - } - - // Find the correct credentials - const selectedCredentials = kpxc.credentials.find(c => c.uuid === uuid); - if (!selectedCredentials) { - console.log('Error: Uuid not found: ', uuid); - return; - } - - // Handle auto-submit - let skipAutoSubmit = false; - if (selectedCredentials.skipAutoSubmit !== undefined) { - skipAutoSubmit = selectedCredentials.skipAutoSubmit === 'true'; - } - - // Fill password - if (combination.password) { - kpxc.setValueWithChange(combination.password, selectedCredentials.password); - await kpxc.setPasswordFilled(true); - } - - // Fill username - if (combination.username && (!combination.username.value || combination.username.value !== usernameValue)) { - if (!passOnly) { - kpxc.setValueWithChange(combination.username, usernameValue); - } - } - - // Fill StringFields - if (selectedCredentials.stringFields && selectedCredentials.stringFields.length > 0) { - kpxc.fillInStringFields(combination.fields, selectedCredentials.stringFields); - } - - // Close autocomplete menu after fill - kpxcUserAutocomplete.closeList(); - - // Reset ManualFill - await sendMessage('page_set_manual_fill', ManualFill.NONE); - - // Auto-submit - const autoSubmitIgnoredForSite = await kpxc.siteIgnored(IGNORE_AUTOSUBMIT); - if (kpxc.settings.autoSubmit && !skipAutoSubmit && !autoSubmitIgnoredForSite) { - const submitButton = kpxcForm.getFormSubmitButton(combination.form); - if (submitButton !== undefined) { - submitButton.click(); - } else if (combination.form) { - combination.form.submit(); - } - } else { - (combination.username || combination.password).focus(); - } -}; - -// Fills StringFields defined in Custom Fields -kpxc.fillInStringFields = function(fields, stringFields) { - const filledInFields = []; - if (fields && stringFields && fields.length > 0 && stringFields.length > 0) { - for (let i = 0; i < fields.length; i++) { - const currentField = fields[i]; - const stringFieldValue = Object.values(stringFields[i]); - - if (currentField && stringFieldValue[0]) { - kpxc.setValue(currentField, stringFieldValue[0]); - filledInFields.push(currentField); - } - } - } -}; - // Get location URL by domain or full URL kpxc.getDocumentLocation = function() { return kpxc.settings.saveDomainOnly ? document.location.origin : document.location.href; @@ -1446,7 +434,7 @@ kpxc.prepareCredentials = async function() { } if (kpxc.settings.autoFillSingleEntry && kpxc.credentials.length === 1) { - kpxc.fillFromAutofill(); + kpxcFill.fillFromAutofill(); return; } @@ -1739,273 +727,6 @@ kpxc.updateTOTPList = async function() { }; -/** - * @Object kpxcObserverHelper - * MutationObserver handler for dynamically added input fields. - */ -const kpxcObserverHelper = {}; -kpxcObserverHelper.ignoredNodeNames = [ 'g', 'path', 'svg', 'A', 'HEAD', 'HTML', 'LABEL', 'LINK', 'SCRIPT', 'SPAN', 'VIDEO' ]; - -kpxcObserverHelper.ignoredNodeTypes = [ - Node.ATTRIBUTE_NODE, - Node.TEXT_NODE, - Node.CDATA_SECTION_NODE, - Node.PROCESSING_INSTRUCTION_NODE, - Node.COMMENT_NODE, - Node.DOCUMENT_TYPE_NODE, - Node.NOTATION_NODE -]; - -kpxcObserverHelper.inputTypes = [ - 'text', - 'email', - 'password', - 'tel', - 'number', - 'username', // Note: Not a standard - undefined, // Input field can be without any type. Include this and null to the list. - null -]; - -// Define what element should be observed by the observer -// and what types of mutations trigger the callback -kpxcObserverHelper.observerConfig = { - subtree: true, - attributes: true, - childList: true, - characterData: true, - attributeFilter: [ 'style', 'class' ] -}; - -// Stores mutation style to an cache array -// If there's a single style mutation, it's safe to calculate it -kpxcObserverHelper.cacheStyle = function(mut, styleMutations, mutationCount) { - if (mut.attributeName !== 'style') { - return; - } - - // If the target is inside a form we are monitoring, calculate the CSS style for better compatibility. - // getComputedStyle() is very slow, so we cannot do that for every style target. - let style = mut.target.style; - if (kpxcForm.formIdentified(mut.target.parentNode) || mutationCount === 1) { - style = getComputedStyle(mut.target); - } - - if (style.display || style.zIndex) { - if (!styleMutations.some(m => m.target === mut.target)) { - styleMutations.push({ - target: mut.target, - display: style.display, - zIndex: style.zIndex - }); - } else { - const currentStyle = styleMutations.find(m => m.target === mut.target); - if (currentStyle - && (currentStyle.display !== style.display - || currentStyle.zIndex !== style.zIndex)) { - currentStyle.display = style.display; - currentStyle.zIndex = style.zIndex; - } - } - } -}; - -// Gets input fields from the target -kpxcObserverHelper.getInputs = function(target, ignoreVisibility = false) { - // Ignores target element if it's not an element node - if (kpxcObserverHelper.ignoredNode(target)) { - return []; - } - - // Filter out any input fields with type 'hidden' right away - const inputFields = []; - Array.from(target.getElementsByTagName('input')).forEach(e => { - if (e.type !== 'hidden' && !e.disabled && !kpxcObserverHelper.alreadyIdentified(e)) { - inputFields.push(e); - } - }); - - if (target.nodeName === 'INPUT') { - inputFields.push(target); - } - - // Append any input fields in Shadow DOM - if (target.shadowRoot && typeof target.shadowSelectorAll === 'function') { - target.shadowSelectorAll('input').forEach(e => { - if (e.type !== 'hidden' && !e.disabled && !kpxcObserverHelper.alreadyIdentified(e)) { - inputFields.push(e); - } - }); - } - - if (inputFields.length === 0) { - return []; - } - - // Do not allow more visible inputs than _maximumInputs (default value: 100) -> return the first 100 - if (inputFields.length > _maximumInputs) { - return inputFields.slice(0, _maximumInputs); - } - - // Only include input fields that match with kpxcObserverHelper.inputTypes - const inputs = []; - for (const field of inputFields) { - if ((!ignoreVisibility && !kpxcFields.isVisible(field)) - || kpxcFields.isSearchField(field)) { - continue; - } - - const type = field.getLowerCaseAttribute('type'); - if (kpxcObserverHelper.inputTypes.includes(type)) { - inputs.push(field); - } - } - - return inputs; -}; - -// Checks if the input field has already identified at page load -kpxcObserverHelper.alreadyIdentified = function(target) { - return kpxc.inputs.some(e => e === target); -}; - -// Adds elements to a monitor array. Identifies the input fields. -kpxcObserverHelper.handleObserverAdd = async function(target) { - if (kpxcObserverHelper.ignoredElement(target)) { - return; - } - - // Sometimes the settings haven't been loaded before new input fields are detected - if (Object.keys(kpxc.settings).length === 0) { - kpxc.init(); - return; - } - - const inputs = kpxcObserverHelper.getInputs(target); - if (inputs.length === 0) { - return; - } - - await kpxc.initCombinations(inputs); - await kpxcIcons.initIcons(kpxc.combinations); - - if (kpxc.databaseState === DatabaseState.UNLOCKED) { - if (_called.retrieveCredentials === false) { - await kpxc.retrieveCredentials(); - return; - } - - kpxc.prepareCredentials(); - } -}; - -// Removes monitored elements -kpxcObserverHelper.handleObserverRemove = function(target) { - if (kpxcObserverHelper.ignoredElement(target)) { - return; - } - - const inputs = kpxcObserverHelper.getInputs(target, true); - if (inputs.length === 0) { - return; - } - - kpxcIcons.deleteHiddenIcons(); -}; - -// Handles CSS transitionend event -kpxcObserverHelper.handleTransitionEnd = function(e) { - if (!e.isTrusted) { - return; - } - - kpxcObserverHelper.handleObserverAdd(e.currentTarget); -}; - -// Returns true if element should be ignored -kpxcObserverHelper.ignoredElement = function(target) { - if (kpxcObserverHelper.ignoredNode(target)) { - return true; - } - - // Ignore elements that do not have a className (including SVG) - if (typeof target.className !== 'string') { - return true; - } - - return false; -}; - -// Ignores all nodes that doesn't contain elements -// Also ignore few Youtube-specific custom nodeNames -kpxcObserverHelper.ignoredNode = function(target) { - if (!target - || kpxcObserverHelper.ignoredNodeTypes.some(e => e === target.nodeType) - || kpxcObserverHelper.ignoredNodeNames.some(e => e === target.nodeName) - || target.nodeName.startsWith('YTMUSIC') - || target.nodeName.startsWith('YT-')) { - return true; - } - - return false; -}; - -// Initializes MutationObserver -kpxcObserverHelper.initObserver = async function() { - kpxc.observer = new MutationObserver(function(mutations, obs) { - if (document.visibilityState === 'hidden' || kpxcUI.mouseDown) { - return; - } - - // Limit the maximum number of mutations - if (mutations.length > _maximumMutations) { - mutations = mutations.slice(0, _maximumMutations); - } - - const styleMutations = []; - for (const mut of mutations) { - if (kpxcObserverHelper.ignoredNode(mut.target)) { - continue; - } - - // Cache style mutations. We only need the last style mutation of the target. - kpxcObserverHelper.cacheStyle(mut, styleMutations, mutations.length); - - if (mut.type === 'childList') { - if (mut.addedNodes.length > 0) { - kpxcObserverHelper.handleObserverAdd(mut.addedNodes[0]); - } else if (mut.removedNodes.length > 0) { - kpxcObserverHelper.handleObserverRemove(mut.removedNodes[0]); - } - } else if (mut.type === 'attributes' && (mut.attributeName === 'class' || mut.attributeName === 'style')) { - // Only accept targets with forms - const forms = mut.target.nodeName === 'FORM' ? mut.target : mut.target.getElementsByTagName('form'); - if (forms.length === 0 && !kpxcSites.exceptionFound(mut.target.classList)) { - continue; - } - - // There's an issue here. We cannot know for sure if the class attribute if added or removed. - kpxcObserverHelper.handleObserverAdd(mut.target); - } - } - - // Handle cached style mutations - for (const styleMut of styleMutations) { - if (styleMut.display !== 'none' && styleMut.display !== '') { - kpxcObserverHelper.handleObserverAdd(styleMut.target); - } else { - kpxcObserverHelper.handleObserverRemove(styleMut.target); - } - } - }); - - if (document.body) { - kpxc.observer.observe(document.body, kpxcObserverHelper.observerConfig); - } -}; - -MutationObserver = window.MutationObserver || window.WebKitMutationObserver; - /** * Content script initialization. */ @@ -2078,21 +799,21 @@ browser.runtime.onMessage.addListener(async function(req, sender) { } else if (req.action === 'clear_credentials') { kpxc.clearAllFromPage(); } else if (req.action === 'fill_user_pass_with_specific_login') { - kpxc.fillFromPopup(req.id, req.uuid); + kpxcFill.fillFromPopup(req.id, req.uuid); } else if (req.action === 'fill_username_password') { sendMessage('page_set_manual_fill', ManualFill.BOTH); await kpxc.receiveCredentialsIfNecessary(); - kpxc.fillInFromActiveElement(); + kpxcFill.fillInFromActiveElement(); } else if (req.action === 'fill_password') { sendMessage('page_set_manual_fill', ManualFill.PASSWORD); await kpxc.receiveCredentialsIfNecessary(); - kpxc.fillInFromActiveElement(true); // passOnly to true + kpxcFill.fillInFromActiveElement(true); // passOnly to true } else if (req.action === 'fill_totp') { await kpxc.receiveCredentialsIfNecessary(); - kpxc.fillFromTOTP(); + kpxcFill.fillFromTOTP(); } else if (req.action === 'fill_attribute' && req.args) { await kpxc.receiveCredentialsIfNecessary(); - kpxc.fillAttributeToActiveElementWith(req.args); + kpxcFill.fillAttributeToActiveElementWith(req.args); } else if (req.action === 'ignore_site') { kpxc.ignoreSite(req.args); } else if (req.action === 'redetect_fields') { diff --git a/keepassxc-browser/content/observer-helper.js b/keepassxc-browser/content/observer-helper.js new file mode 100644 index 0000000..19cf9f8 --- /dev/null +++ b/keepassxc-browser/content/observer-helper.js @@ -0,0 +1,271 @@ +'use strict'; + +const MAX_INPUTS = 100; +const MAX_MUTATIONS = 200; + +MutationObserver = window.MutationObserver || window.WebKitMutationObserver; + +/** + * @Object kpxcObserverHelper + * MutationObserver handler for dynamically added input fields. + */ +const kpxcObserverHelper = {}; +kpxcObserverHelper.ignoredNodeNames = [ 'g', 'path', 'svg', 'A', 'HEAD', 'HTML', 'LABEL', 'LINK', 'SCRIPT', 'SPAN', 'VIDEO' ]; + +kpxcObserverHelper.ignoredNodeTypes = [ + Node.ATTRIBUTE_NODE, + Node.TEXT_NODE, + Node.CDATA_SECTION_NODE, + Node.PROCESSING_INSTRUCTION_NODE, + Node.COMMENT_NODE, + Node.DOCUMENT_TYPE_NODE, + Node.NOTATION_NODE +]; + +kpxcObserverHelper.inputTypes = [ + 'text', + 'email', + 'password', + 'tel', + 'number', + 'username', // Note: Not a standard + undefined, // Input field can be without any type. Include this and null to the list. + null +]; + +// Define what element should be observed by the observer +// and what types of mutations trigger the callback +kpxcObserverHelper.observerConfig = { + subtree: true, + attributes: true, + childList: true, + characterData: true, + attributeFilter: [ 'style', 'class' ] +}; + +// Initializes MutationObserver +kpxcObserverHelper.initObserver = async function() { + kpxc.observer = new MutationObserver(function(mutations, obs) { + if (document.visibilityState === 'hidden' || kpxcUI.mouseDown) { + return; + } + + // Limit the maximum number of mutations + if (mutations.length > MAX_MUTATIONS) { + mutations = mutations.slice(0, MAX_MUTATIONS); + } + + const styleMutations = []; + for (const mut of mutations) { + if (kpxcObserverHelper.ignoredNode(mut.target)) { + continue; + } + + // Cache style mutations. We only need the last style mutation of the target. + kpxcObserverHelper.cacheStyle(mut, styleMutations, mutations.length); + + if (mut.type === 'childList') { + if (mut.addedNodes.length > 0) { + kpxcObserverHelper.handleObserverAdd(mut.addedNodes[0]); + } else if (mut.removedNodes.length > 0) { + kpxcObserverHelper.handleObserverRemove(mut.removedNodes[0]); + } + } else if (mut.type === 'attributes' && (mut.attributeName === 'class' || mut.attributeName === 'style')) { + // Only accept targets with forms + const forms = mut.target.nodeName === 'FORM' ? mut.target : mut.target.getElementsByTagName('form'); + if (forms.length === 0 && !kpxcSites.exceptionFound(mut.target.classList)) { + continue; + } + + // There's an issue here. We cannot know for sure if the class attribute if added or removed. + kpxcObserverHelper.handleObserverAdd(mut.target); + } + } + + // Handle cached style mutations + for (const styleMut of styleMutations) { + if (styleMut.display !== 'none' && styleMut.display !== '') { + kpxcObserverHelper.handleObserverAdd(styleMut.target); + } else { + kpxcObserverHelper.handleObserverRemove(styleMut.target); + } + } + }); + + if (document.body) { + kpxc.observer.observe(document.body, kpxcObserverHelper.observerConfig); + } +}; + +// Stores mutation style to an cache array +// If there's a single style mutation, it's safe to calculate it +kpxcObserverHelper.cacheStyle = function(mut, styleMutations, mutationCount) { + if (mut.attributeName !== 'style') { + return; + } + + // If the target is inside a form we are monitoring, calculate the CSS style for better compatibility. + // getComputedStyle() is very slow, so we cannot do that for every style target. + let style = mut.target.style; + if (kpxcForm.formIdentified(mut.target.parentNode) || mutationCount === 1) { + style = getComputedStyle(mut.target); + } + + if (style.display || style.zIndex) { + if (!styleMutations.some(m => m.target === mut.target)) { + styleMutations.push({ + target: mut.target, + display: style.display, + zIndex: style.zIndex + }); + } else { + const currentStyle = styleMutations.find(m => m.target === mut.target); + if (currentStyle + && (currentStyle.display !== style.display + || currentStyle.zIndex !== style.zIndex)) { + currentStyle.display = style.display; + currentStyle.zIndex = style.zIndex; + } + } + } +}; + +// Gets input fields from the target +kpxcObserverHelper.getInputs = function(target, ignoreVisibility = false) { + // Ignores target element if it's not an element node + if (kpxcObserverHelper.ignoredNode(target)) { + return []; + } + + // Filter out any input fields with type 'hidden' right away + const inputFields = []; + Array.from(target.getElementsByTagName('input')).forEach(e => { + if (e.type !== 'hidden' && !e.disabled && !kpxcObserverHelper.alreadyIdentified(e)) { + inputFields.push(e); + } + }); + + if (target.nodeName === 'INPUT') { + inputFields.push(target); + } + + // Append any input fields in Shadow DOM + if (target.shadowRoot && typeof target.shadowSelectorAll === 'function') { + target.shadowSelectorAll('input').forEach(e => { + if (e.type !== 'hidden' && !e.disabled && !kpxcObserverHelper.alreadyIdentified(e)) { + inputFields.push(e); + } + }); + } + + if (inputFields.length === 0) { + return []; + } + + // Do not allow more visible inputs than _maximumInputs (default value: 100) -> return the first 100 + if (inputFields.length > MAX_INPUTS) { + return inputFields.slice(0, MAX_INPUTS); + } + + // Only include input fields that match with kpxcObserverHelper.inputTypes + const inputs = []; + for (const field of inputFields) { + if ((!ignoreVisibility && !kpxcFields.isVisible(field)) + || kpxcFields.isSearchField(field)) { + continue; + } + + const type = field.getLowerCaseAttribute('type'); + if (kpxcObserverHelper.inputTypes.includes(type)) { + inputs.push(field); + } + } + + return inputs; +}; + +// Checks if the input field has already identified at page load +kpxcObserverHelper.alreadyIdentified = function(target) { + return kpxc.inputs.some(e => e === target); +}; + +// Adds elements to a monitor array. Identifies the input fields. +kpxcObserverHelper.handleObserverAdd = async function(target) { + if (kpxcObserverHelper.ignoredElement(target)) { + return; + } + + // Sometimes the settings haven't been loaded before new input fields are detected + if (Object.keys(kpxc.settings).length === 0) { + kpxc.init(); + return; + } + + const inputs = kpxcObserverHelper.getInputs(target); + if (inputs.length === 0) { + return; + } + + await kpxc.initCombinations(inputs); + await kpxcIcons.initIcons(kpxc.combinations); + + if (kpxc.databaseState === DatabaseState.UNLOCKED) { + if (_called.retrieveCredentials === false) { + await kpxc.retrieveCredentials(); + return; + } + + kpxc.prepareCredentials(); + } +}; + +// Removes monitored elements +kpxcObserverHelper.handleObserverRemove = function(target) { + if (kpxcObserverHelper.ignoredElement(target)) { + return; + } + + const inputs = kpxcObserverHelper.getInputs(target, true); + if (inputs.length === 0) { + return; + } + + kpxcIcons.deleteHiddenIcons(); +}; + +// Handles CSS transitionend event +kpxcObserverHelper.handleTransitionEnd = function(e) { + if (!e.isTrusted) { + return; + } + + kpxcObserverHelper.handleObserverAdd(e.currentTarget); +}; + +// Returns true if element should be ignored +kpxcObserverHelper.ignoredElement = function(target) { + if (kpxcObserverHelper.ignoredNode(target)) { + return true; + } + + // Ignore elements that do not have a className (including SVG) + if (typeof target.className !== 'string') { + return true; + } + + return false; +}; + +// Ignores all nodes that doesn't contain elements +// Also ignore few Youtube-specific custom nodeNames +kpxcObserverHelper.ignoredNode = function(target) { + if (!target + || kpxcObserverHelper.ignoredNodeTypes.some(e => e === target.nodeType) + || kpxcObserverHelper.ignoredNodeNames.some(e => e === target.nodeName) + || target.nodeName.startsWith('YTMUSIC') + || target.nodeName.startsWith('YT-')) { + return true; + } + + return false; +}; diff --git a/keepassxc-browser/content/pwgen.js b/keepassxc-browser/content/pwgen.js index 9bcb263..2c24909 100644 --- a/keepassxc-browser/content/pwgen.js +++ b/keepassxc-browser/content/pwgen.js @@ -100,7 +100,11 @@ PasswordIcon.prototype.createIcon = function(field) { document.body.append(wrapper); }; - +/** + * @Object kpxcPasswordDialog + * Provides a password dialog for content scripts. + * TODO: To be removed when KeePassXC 2.8.0 is released. 2.7.0 already uses KeePassXC's own password generator instead. + */ const kpxcPasswordDialog = {}; kpxcPasswordDialog.created = false; kpxcPasswordDialog.icon = null; diff --git a/keepassxc-browser/content/totp-autocomplete.js b/keepassxc-browser/content/totp-autocomplete.js index f90c9ff..1445d60 100644 --- a/keepassxc-browser/content/totp-autocomplete.js +++ b/keepassxc-browser/content/totp-autocomplete.js @@ -30,7 +30,7 @@ TOTPAutocomplete.prototype.fillTotp = async function(index, uuid) { const combination = await kpxcFields.getCombination(this.input, 'totp') || await kpxcFields.getCombination(this.input, 'totpInputs'); combination.loginId = index; - kpxc.fillTOTPFromUuid(this.input, uuid); + kpxcFill.fillTOTPFromUuid(this.input, uuid); }; const kpxcTOTPAutocomplete = new TOTPAutocomplete(); diff --git a/keepassxc-browser/content/totp-field.js b/keepassxc-browser/content/totp-field.js index 231c64f..b7eb204 100644 --- a/keepassxc-browser/content/totp-field.js +++ b/keepassxc-browser/content/totp-field.js @@ -134,7 +134,7 @@ TOTPFieldIcon.prototype.createIcon = function(field, segmented = false) { e.stopPropagation(); await kpxc.receiveCredentialsIfNecessary(); - kpxc.fillFromTOTP(field); + kpxcFill.fillFromTOTP(field); }); icon.addEventListener('mousedown', ev => ev.stopPropagation()); diff --git a/keepassxc-browser/content/username-field.js b/keepassxc-browser/content/username-field.js index 7af53fe..1e756ae 100644 --- a/keepassxc-browser/content/username-field.js +++ b/keepassxc-browser/content/username-field.js @@ -157,5 +157,5 @@ const getIconText = function(state) { const fillCredentials = async function(field) { const combination = await kpxcFields.getCombination(field); - kpxc.fillFromUsernameIcon(combination); + kpxcFill.fillFromUsernameIcon(combination); }; diff --git a/keepassxc-browser/manifest.json b/keepassxc-browser/manifest.json index fda696e..a4d73c2 100755 --- a/keepassxc-browser/manifest.json +++ b/keepassxc-browser/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 2, "name": "KeePassXC-Browser", - "version": "1.7.11", - "version_name": "1.7.11", + "version": "1.8.0", + "version_name": "1.8.0", "description": "__MSG_extensionDescription__", "author": "KeePassXC Team", "icons": { @@ -58,7 +58,12 @@ "content/autocomplete.js", "content/credential-autocomplete.js", "content/define.js", + "content/fields.js", + "content/fill.js", + "content/form.js", + "content/icons.js", "content/keepassxc-browser.js", + "content/observer-helper.js", "content/pwgen.js", "content/totp-autocomplete.js", "content/totp-field.js", diff --git a/package-lock.json b/package-lock.json index 6633bbe..465e6f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "KeePassXC-Browser", - "version": "1.7.10.1", + "version": "1.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "KeePassXC-Browser", - "version": "1.7.10.1", + "version": "1.8.0", "license": "GPL-3.0", "dependencies": { "file-url": "^3.0.0", diff --git a/package.json b/package.json index 6045325..9ff703b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "KeePassXC-Browser", - "version": "1.7.11", + "version": "1.8.0", "description": "KeePassXC-Browser", "main": "build.js", "devDependencies": {