diff --git a/README.md b/README.md index 2edf1bf..5b16c7e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ KeePassXC-Browser extension requests the following permissions: | ----- | ----- | | `activeTab` | To get URL of the current tab | | `contextMenus` | To show context menu items | +| `cookies` | To access browser's internal Public Suffix List | | `clipboardWrite` | Allows password to be copied from password generator to clipboard | | `nativeMessaging` | Allows communication with KeePassXC application | | `notifications` | To show browser notifications | @@ -42,7 +43,7 @@ KeePassXC-Browser extension requests the following permissions: ## Protocol -The details about the messaging protocol used with the browser extension and KeePassXC can be found [here](keepassxc-protocol.md). +Check [keepassxc-protocol](keepassxc-protocol.md) for the details about the messaging protocol used between the browser extension and KeePassXC. ## Translations diff --git a/keepassxc-browser/_locales/en/messages.json b/keepassxc-browser/_locales/en/messages.json index 1e1ecc9..b6140ee 100644 --- a/keepassxc-browser/_locales/en/messages.json +++ b/keepassxc-browser/_locales/en/messages.json @@ -387,6 +387,10 @@ "message": "Add Username-only option for the site", "description": "Button text for adding Username-only option." }, + "popupAllowIframeButton": { + "message": "Allow Cross-Origin iframes for the site", + "description": "Button text for allowing Cross-Origin iframes option." + }, "popupErrorEncountered": { "message": "KeePassXC-Browser has encountered an error:", "description": "A text shown above error message in the popup." @@ -447,6 +451,10 @@ "message": "Only a single username field was detected. Add the URL to Site Preferences with Username-only option enabled?", "description": "Text shown when page can be added to Site Preferences with Username-only option enabled." }, + "popupIframeDetected": { + "message": "A Cross-Origin iframe was detected. Add the URL to Site Preferences with Allow Cross-Origin iframes option enabled?", + "description": "Text shown when page can be added to Site Preferences with Allow Cross-Origin iframes option enabled." + }, "rememberInfoText": { "message": "Username or password changed! Save it?", "description": "Message when username or password has changed." @@ -1015,6 +1023,10 @@ "message": "Improved Input Field Detection", "description": "Improved Input Field Detection column text." }, + "optionsColumnAllowIframes": { + "message": "Allow Cross-Origin iframes", + "description": "Allow iframes column text." + }, "optionsColumnDelete": { "message": "Delete", "description": "Site preferences list column title." @@ -1135,6 +1147,10 @@ "message": "Improved Input Field Detection allows more detailed dynamic input field detection. However, it might affect the page performance. Use with caution.", "description": "Improved Input Field Detection help text." }, + "optionsSitePreferencesAllowIframesHelpText": { + "message": "Allowing Cross-Origin iframes will enable credential retrieval for iframes from another domain. Use at your own risk.", + "description": "Allow Cross-Origin iframes help text." + }, "optionsSitePreferencesManualAddText": { "message": "Add URL manually", "description": "Label for adding site manually on Site preferences tab." diff --git a/keepassxc-browser/background/event.js b/keepassxc-browser/background/event.js index 06e4625..ebb197b 100755 --- a/keepassxc-browser/background/event.js +++ b/keepassxc-browser/background/event.js @@ -26,18 +26,21 @@ kpxcEvent.showStatus = async function(tab, configured, internalPoll) { const errorMessage = page.tabs[tab.id]?.errorMessage ?? undefined; const usernameFieldDetected = page.tabs[tab.id]?.usernameFieldDetected ?? false; + const iframeDetected = page.tabs[tab.id]?.iframeDetected ?? false; return { - identifier: keyId, + associated: keepass.isAssociated(), + configured: configured, databaseClosed: keepass.isDatabaseClosed, - keePassXCAvailable: keepass.isKeePassXCAvailable, encryptionKeyUnrecognized: keepass.isEncryptionKeyUnrecognized, - associated: keepass.isAssociated(), error: errorMessage, - usernameFieldDetected: usernameFieldDetected, + iframeDetected: iframeDetected, + identifier: keyId, + keePassXCAvailable: keepass.isKeePassXCAvailable, showGettingStartedGuideAlert: page.settings.showGettingStartedGuideAlert, - showTroubleshootingGuideAlert: page.settings.showTroubleshootingGuideAlert + showTroubleshootingGuideAlert: page.settings.showTroubleshootingGuideAlert, + usernameFieldDetected: usernameFieldDetected }; }; @@ -177,6 +180,10 @@ kpxcEvent.onUsernameFieldDetected = async function(tab, detected) { page.tabs[tab.id].usernameFieldDetected = detected; }; +kpxcEvent.onIframeDetected = async function(tab, detected) { + page.tabs[tab.id].iframeDetected = detected; +}; + kpxcEvent.passwordGetFilled = async function() { return page.passwordFilled; }; @@ -251,8 +258,10 @@ kpxcEvent.messageHandlers = { 'get_totp': keepass.getTotp, 'hide_getting_started_guide_alert': kpxcEvent.hideGettingStartedGuideAlert, 'hide_troubleshooting_guide_alert': kpxcEvent.hideTroubleshootingGuideAlert, + 'iframe_detected': kpxcEvent.onIframeDetected, 'init_http_auth': kpxcEvent.initHttpAuth, 'is_connected': kpxcEvent.getIsKeePassXCAvailable, + 'is_iframe_allowed': page.isIframeAllowed, 'load_keyring': kpxcEvent.onLoadKeyRing, 'load_settings': kpxcEvent.onLoadSettings, 'lock_database': kpxcEvent.lockDatabase, @@ -263,6 +272,7 @@ kpxcEvent.messageHandlers = { 'page_get_manual_fill': page.getManualFill, 'page_get_redirect_count': kpxcEvent.pageGetRedirectCount, 'page_get_submitted': page.getSubmitted, + 'page_set_allow_iframes': page.setAllowIframes, 'page_set_autosubmit_performed': page.setAutoSubmitPerformed, 'page_set_login_id': page.setLoginId, 'page_set_manual_fill': page.setManualFill, diff --git a/keepassxc-browser/background/keepass.js b/keepassxc-browser/background/keepass.js index cc50daa..0db8141 100755 --- a/keepassxc-browser/background/keepass.js +++ b/keepassxc-browser/background/keepass.js @@ -582,7 +582,7 @@ keepass.requestAutotype = async function(tab, args = []) { const kpAction = kpActions.REQUEST_AUTOTYPE; const nonce = keepassClient.getNonce(); - const search = getTopLevelDomainFromUrl(args[0]); + const search = page.getTopLevelDomainFromUrl(args[0]); const messageData = { action: kpAction, diff --git a/keepassxc-browser/background/page.js b/keepassxc-browser/background/page.js index d7c7495..861f7d6 100755 --- a/keepassxc-browser/background/page.js +++ b/keepassxc-browser/background/page.js @@ -155,6 +155,7 @@ page.clearLogins = function(tabId) { return; } + page.tabs[tabId].allowIframes = false; page.tabs[tabId].credentials = []; page.tabs[tabId].loginList = []; page.currentRequest = {}; @@ -186,6 +187,7 @@ page.clearSubmittedCredentials = async function() { page.createTabEntry = function(tabId) { page.tabs[tabId] = { + allowIframes: false, credentials: [], errorMessage: null, loginList: [], @@ -330,6 +332,101 @@ page.updatePopup = function(tab) { browserAction.showDefault(tab); }; +page.setAllowIframes = async function(tab, args = []) { + const [ allowIframes, site ] = args; + + // Only set when main windows' URL is used + if (tab?.url === site) { + page.tabs[tab.id].allowIframes = allowIframes; + } +}; + +page.isIframeAllowed = async function(tab, args = []) { + const [ url, hostname ] = args; + const baseDomain = await page.getBaseDomainFromUrl(hostname, url); + + // Allow if exception has been set from Site Preferences + if (page.tabs[tab.id]?.allowIframes) { + return true; + } + + // Allow iframe if the base domain is included in iframes' and tab's hostname + const tabUrl = new URL(tab?.url); + return hostname.endsWith(baseDomain) && tabUrl.hostname?.endsWith(baseDomain); +}; + +/** + * Gets the top level domain from URL. + * @param {string} domain Current iframe's hostname + * @param {string} url Current iframe's full URL + * @returns {string} TLD e.g. https://another.example.co.uk -> co.uk + */ +page.getTopLevelDomainFromUrl = async function(domain, url) { + // A simple check for IPv4 address. TLD cannot be numeric, and if hostname is just numbers, it's probably an IPv4. + // TODO: Handle IPv6 addresses. Is there some internal API for these? + if (!isNaN(Number(domain?.replaceAll('.', '')))) { + return domain; + } + + // Only loop the amount of different domain parts found + const numberOfDomainParts = domain?.split('.')?.length; + for (let i = 0; i < numberOfDomainParts; ++i) { + // Cut the first part from host + const index = domain?.indexOf('.'); + if (index < 0) { + continue; + } + + // Check if dummy cookie's domain/TLD matches with public suffix list. + // If setting the cookie fails, TLD has been found. + try { + domain = domain?.substring(index + 1); + const reply = await browser.cookies.set({ + domain: domain, + name: 'kpxc', + sameSite: 'strict', + url: url, + value: '' + }); + + // Delete the temporary cookie immediately + if (reply) { + await browser.cookies.remove({ + name: 'kpxc', + url: url + }); + } + } catch (e) { + return domain; + } + } + + return domain; +}; + +/** + * Gets the base domain of URL or hostname. + * Up-to-date list can be found: https://publicsuffix.org/list/public_suffix_list.dat + * @param {string} domain Current iframe's hostname + * @param {string} url Current iframe's full URL + * @returns {string} The base domain, e.g. https://another.example.co.uk -> example.co.uk + */ +page.getBaseDomainFromUrl = async function(hostname, url) { + const tld = await page.getTopLevelDomainFromUrl(hostname, url); + if (tld.length === 0 || tld === hostname) { + return hostname; + } + + // Remove the top level domain part from the hostname, e.g. https://another.example.co.uk -> https://another.example + const finalDomain = hostname.slice(0, hostname.indexOf(tld) - 1); + // Split the URL and select the last part, e.g. https://another.example -> example + let baseDomain = finalDomain.split('.')?.at(-1); + // Append the top level domain back to the URL, e.g. example -> example.co.uk + baseDomain = baseDomain + '.' + tld; + + return baseDomain; +}; + const createContextMenuItem = function({ action, args, ...options }) { return browser.contextMenus.create({ contexts: menuContexts, @@ -345,6 +442,7 @@ const createContextMenuItem = function({ action, args, ...options }) { }); }; + const logDebug = function(message, extra) { if (page.settings.debugLogging) { debugLogMessage(message, extra); diff --git a/keepassxc-browser/common/global.js b/keepassxc-browser/common/global.js index fe84b65..43e2c55 100755 --- a/keepassxc-browser/common/global.js +++ b/keepassxc-browser/common/global.js @@ -111,19 +111,6 @@ const slashNeededForUrl = function(pattern) { return matchPattern.exec(pattern); }; -// Returns the top level domain, e.g. https://another.example.co.uk -> example.co.uk -// This is done because a top level domain will probably give better matches with Auto-Type than a full hostname. -const getTopLevelDomainFromUrl = function(hostname) { - const domainRegex = new RegExp(/(\w+).(com|net|org|edu|co)*(.\w+)$/g); - const domainMatch = domainRegex.exec(hostname); - - if (domainMatch) { - return domainMatch[0]; - } - - return hostname; -}; - function tr(key, params) { return browser.i18n.getMessage(key, params); } diff --git a/keepassxc-browser/content/keepassxc-browser.js b/keepassxc-browser/content/keepassxc-browser.js index 3c60bb2..4e82495 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -27,8 +27,8 @@ kpxc.singleInputEnabledForPage = false; kpxc.submitUrl = null; kpxc.url = null; -// Add page to Site Preferences with Username-only detection enabled. Set from the popup -kpxc.addToSitePreferences = async function() { +// Add page to Site Preferences with a selected option enabled. Set from the popup. +kpxc.addToSitePreferences = async function(optionName, addWildcard = false) { // Returns a predefined URL for certain sites let site = trimURL(window.top.location.href).toLowerCase(); @@ -37,24 +37,32 @@ kpxc.addToSitePreferences = async function() { for (const existingSite of kpxc.settings['sitePreferences']) { if (existingSite.url === site) { existingSite.ignore = IGNORE_NOTHING; - existingSite.usernameOnly = true; + existingSite[optionName] = true; siteExists = true; } } if (!siteExists) { // Add wildcard to the URL - site = site.slice(0, site.lastIndexOf('/') + 1) + '*'; + if (addWildcard) { + site = site.slice(0, site.lastIndexOf('/') + 1) + '*'; + } kpxc.settings['sitePreferences'].push({ url: site, ignore: IGNORE_NOTHING, - usernameOnly: true + [optionName]: true }); } await sendMessage('save_settings', kpxc.settings); - sendMessage('username_field_detected', false); + + if (optionName === 'allowIframes') { + await sendMessage('page_set_allow_iframes', [ true, site ]); + await sendMessage('iframe_detected', false); + } else if (optionName === 'usernameOnly') { + await sendMessage('username_field_detected', false); + } }; // Clears all from the content and background scripts, including autocomplete @@ -584,7 +592,7 @@ kpxc.rememberCredentialsFromContextMenu = async function() { // The basic function for retrieving credentials from KeePassXC. // Credential Banner can force the retrieval for reloading new/modified credentials. kpxc.retrieveCredentials = async function(force = false) { - if (!isIframeAllowed()) { + if (!await isIframeAllowed()) { return []; } @@ -623,7 +631,7 @@ kpxc.retrieveCredentialsCallback = async function(credentials) { // If credentials are not received, request them again kpxc.receiveCredentialsIfNecessary = async function() { if (kpxc.credentials.length === 0 && !_called.retrieveCredentials) { - if (!isIframeAllowed()) { + if (!await isIframeAllowed()) { return []; } @@ -710,6 +718,7 @@ kpxc.siteIgnored = async function(condition) { currentLocation = window.self.location.href.toLowerCase(); } + // Refresh current settings for the site const currentSetting = condition || IGNORE_FULL; for (const site of kpxc.settings.sitePreferences) { if (siteMatch(site.url, currentLocation) || site.url === currentLocation) { @@ -719,6 +728,7 @@ kpxc.siteIgnored = async function(condition) { kpxc.singleInputEnabledForPage = site.usernameOnly; kpxc.improvedFieldDetectionEnabledForPage = site.improvedFieldDetection; + await sendMessage('page_set_allow_iframes', [ site.allowIframes, currentLocation ]); } } @@ -901,8 +911,10 @@ browser.runtime.onMessage.addListener(async function(req, sender) { if (req.action === 'activated_tab') { kpxc.triggerActivatedTab(); + } else if (req.action === 'add_allow_iframes_option') { + kpxc.addToSitePreferences('allowIframes'); } else if (req.action === 'add_username_only_option') { - kpxc.addToSitePreferences(); + kpxc.addToSitePreferences('usernameOnly', true); } else if (req.action === 'check_database_hash' && 'hash' in req) { kpxc.detectDatabaseChange(req); } else if (req.action === 'choose_credential_fields') { @@ -972,13 +984,21 @@ kpxc.reconnect = async function() { return true; }; -// Check for Cross-domain security error when inspecting window.top.location.href -const isIframeAllowed = function() { +const isIframeAllowed = async function() { + sendMessage('iframe_detected', false); try { + // Check for Cross-domain security error when inspecting window.top.location.href const currentLocation = window.top.location.href; return true; } catch (err) { + // Inspect iframe using TLD and the tab's original URL + const allowed = await sendMessage('is_iframe_allowed', [ window.location.href, window.location.hostname ]); + if (allowed) { + return true; + } + logDebug(`Error: Credential request ignored from another domain: ${window.self.location.host}`); + sendMessage('iframe_detected', true); return false; } }; diff --git a/keepassxc-browser/manifest.json b/keepassxc-browser/manifest.json index 655be6d..d52670f 100755 --- a/keepassxc-browser/manifest.json +++ b/keepassxc-browser/manifest.json @@ -145,6 +145,7 @@ "permissions": [ "activeTab", "contextMenus", + "cookies", "clipboardWrite", "nativeMessaging", "notifications", diff --git a/keepassxc-browser/options/options.css b/keepassxc-browser/options/options.css index 77f3bd2..da74ffe 100644 --- a/keepassxc-browser/options/options.css +++ b/keepassxc-browser/options/options.css @@ -128,6 +128,7 @@ table tbody tr.empty:not(:nth-last-child(2)) { #tab-site-preferences td:nth-of-type(3), #tab-site-preferences td:nth-of-type(4), +#tab-site-preferences td:nth-of-type(5), table td:last-of-type { width: 1px; } diff --git a/keepassxc-browser/options/options.html b/keepassxc-browser/options/options.html index 6134aa4..bee629c 100644 --- a/keepassxc-browser/options/options.html +++ b/keepassxc-browser/options/options.html @@ -69,7 +69,7 @@ @@ -695,6 +695,7 @@