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/background/event.js b/keepassxc-browser/background/event.js index 06e4625..583f8c4 100755 --- a/keepassxc-browser/background/event.js +++ b/keepassxc-browser/background/event.js @@ -253,6 +253,7 @@ kpxcEvent.messageHandlers = { 'hide_troubleshooting_guide_alert': kpxcEvent.hideTroubleshootingGuideAlert, '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, 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 0774047..5a0a49b 100755 --- a/keepassxc-browser/background/page.js +++ b/keepassxc-browser/background/page.js @@ -329,6 +329,87 @@ page.updatePopup = function(tab) { browserAction.showDefault(tab); }; +page.isIframeAllowed = async function(tab, args = []) { + const [ url, hostname ] = args; + const baseDomain = await page.getBaseDomainFromUrl(hostname, url); + + // 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, @@ -344,6 +425,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 efde0a3..9fd84d5 100755 --- a/keepassxc-browser/content/keepassxc-browser.js +++ b/keepassxc-browser/content/keepassxc-browser.js @@ -580,7 +580,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 []; } @@ -619,7 +619,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 []; } @@ -968,12 +968,19 @@ 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() { 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; + } + + // TODO: Allow user to add a manual exception for iframes with this tab URL logDebug(`Error: Credential request ignored from another domain: ${window.self.location.host}`); return false; } diff --git a/keepassxc-browser/manifest.json b/keepassxc-browser/manifest.json index 202781b..7edd71a 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/tests/tests.js b/tests/tests.js index ed69f32..23b08c7 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -72,18 +72,40 @@ async function testGeneral() { assertRegex(siteMatch(m[0], m[1]), m[2], testCard, `siteMatch() for ${m[1]}`); } - // Base domain parsing (window.location.hostname) + // Top-Level-Domain tests + const tldUrls = [ + [ 'another.example.co.uk', 'co.uk' ], + [ 'www.example.com', 'com' ], + [ 'github.com', 'com' ], + [ 'test.net', 'net' ], + [ 'so.many.subdomains.co.jp', 'co.jp' ], + [ '192.168.0.1', '192.168.0.1' ], + [ 'www.nic.ar','ar' ], + [ 'no.no.no', 'no' ], + [ 'www.blogspot.com.ar', 'blogspot.com.ar' ], // blogspot.com.ar is a TLD + [ 'jap.an.ide.kyoto.jp', 'ide.kyoto.jp' ], // ide.kyoto.jp is a TLD + ]; + + for (const d of domains) { + kpxcAssert(page.getTopLevelDomainFromUrl(d[0]), d[1], testCard, 'getTopLevelDomainFromUrl() for ' + d[0]); + } + + // Base domain tests const domains = [ [ 'another.example.co.uk', 'example.co.uk' ], [ 'www.example.com', 'example.com' ], [ 'test.net', 'test.net' ], [ 'so.many.subdomains.co.jp', 'subdomains.co.jp' ], - [ 'test.site.example.com.au', 'example.com.au' ], - [ '192.168.0.1', '192.168.0.1' ] + [ '192.168.0.1', '192.168.0.1' ], + [ 'www.nic.ar', 'nic.ar' ], + [ 'www.blogspot.com.ar', 'www.blogspot.com.ar' ], // blogspot.com.ar is a TLD + [ 'www.arpa', 'www.arpa' ], + [ 'jap.an.ide.kyoto.jp', 'an.ide.kyoto.jp' ], // ide.kyoto.jp is a TLD + [ 'kobe.jp', 'kobe.jp' ], ]; for (const d of domains) { - kpxcAssert(getTopLevelDomainFromUrl(d[0]), d[1], testCard, 'getBaseDomainFromUrl() for ' + d[0]); + kpxcAssert(page.getBaseDomainFromUrl(d[0]), d[1], testCard, 'getBaseDomainFromUrl() for ' + d[0]); } }