From f19a44d3ce03440717230703e3b62b0458533568 Mon Sep 17 00:00:00 2001 From: Erayd Date: Sun, 13 Oct 2019 11:30:46 +1300 Subject: [PATCH] Replace tldjs with @browserpass/url, check port (#179) --- README.md | 4 ++- src/background.js | 33 ++++++++++-------- src/helpers.js | 65 ++++++++++++++++++++++++++---------- src/package.json | 4 +-- src/popup/popup.js | 2 +- src/popup/searchinterface.js | 3 +- src/yarn.lock | 16 ++++----- 7 files changed, 82 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e291d6b..38e42fe 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ If not, repeat the installation instructions for the extension. Browserpass was designed with an assumption that certain conventions are being followed when organizing your password store. -1. In order to benefit of phishing attack protection, a password entry file, or any of its parent folders, must contain a full domain name (including TLD like `.com`) in their name in order to automatically match a website. However, entries which do not contain such a domain in their name may still be manually selected. +1. In order to benefit of phishing attack protection, a password entry file, or any of its parent folders, must contain a full domain name (including TLD like `.com`) and optionally port in their name in order to automatically match a website. However, entries which do not contain such a domain in their name may still be manually selected. File names are not allowed to contain `\` or `/` characters, because both of them are considered to be path separators. @@ -171,6 +171,8 @@ In order for Browserpass to correctly determine matching entries, it is expected Browserpass will display entries for the current domain, as well as all parent entries, but not entries from different subdomains. Suppose you are currently on `https://v3.app.example.com`, Browserpass will present all the following entries in popup (if they exist): `v3.app.example.com`, `app.example.com`, `example.com`; but it will not present entries like `v2.app.example.com` or `wiki.example.com`. +Browserpass can also distinguish credentials meant for different ports, so for example an entry `example.com.gpg` will show up in Browserpass popup when you browse `example.com` on any port, however an entry `example.com:8080.gpg` will only show up on `8080` port. + Finally Browserpass will also present entries that you have recently used on this domain, even if they don't actually meet the usual matching requirements. Suppose you have a password for `amazon.com`, but you open `https://amazon.co.uk`, at first Browserpass will present no entries (because nothing matches `amazon.co.uk`), but if you hit Backspace, find `amazon.com` and use it to login, next time you visit `https://amazon.co.uk` and open Browserpass, `amazon.com` entry will already be present. The sorting algorithm implemented in Browserpass will use several intuitions to try to order results in the most expected way for a user: diff --git a/src/background.js b/src/background.js index bfc8d4e..6b16ea3 100644 --- a/src/background.js +++ b/src/background.js @@ -4,6 +4,7 @@ require("chrome-extension-async"); const sha1 = require("sha1"); const idb = require("idb"); +const BrowserpassURL = require("@browserpass/url"); const helpers = require("./helpers"); // native application id @@ -137,7 +138,7 @@ async function updateMatchingPasswordsCount(tabId, forceRefresh = false) { try { const tab = await chrome.tabs.get(tabId); - badgeCache.settings.host = new URL(tab.url).hostname; + badgeCache.settings.origin = new URL(tab.url).origin; } catch (e) { throw new Error(`Unable to determine domain of the tab with id ${tabId}`); } @@ -146,7 +147,7 @@ async function updateMatchingPasswordsCount(tabId, forceRefresh = false) { const files = helpers.ignoreFiles(badgeCache.files, badgeCache.settings); const logins = helpers.prepareLogins(files, badgeCache.settings); const matchedPasswordsCount = logins.reduce( - (acc, login) => acc + (login.recent.count || login.inCurrentDomain ? 1 : 0), + (acc, login) => acc + (login.recent.count || login.inCurrentHost ? 1 : 0), 0 ); @@ -228,13 +229,14 @@ async function saveRecent(settings, login, remove = false) { login.recent.count++; } login.recent.when = Date.now(); - settings.recent[sha1(settings.host + sha1(login.store.id + sha1(login.login)))] = login.recent; + settings.recent[sha1(settings.origin + sha1(login.store.id + sha1(login.login)))] = + login.recent; // save to local storage localStorage.setItem("recent", JSON.stringify(settings.recent)); // a new entry was added to the popup matching list, need to refresh the count - if (!login.inCurrentDomain && login.recent.count === 1) { + if (!login.inCurrentHost && login.recent.count === 1) { updateMatchingPasswordsCount(settings.tab.id, true); } @@ -246,7 +248,7 @@ async function saveRecent(settings, login, remove = false) { db.createObjectStore("log", { keyPath: "time" }); } }); - await db.add("log", { time: Date.now(), host: settings.host, login: login.login }); + await db.add("log", { time: Date.now(), host: settings.origin, login: login.login }); } catch { // ignore any errors and proceed without saving a log entry to Indexed DB } @@ -266,7 +268,7 @@ async function dispatchFill(settings, request, allFrames, allowForeign, allowNoS request = Object.assign(deepCopy(request), { allowForeign: allowForeign, allowNoSecret: allowNoSecret, - foreignFills: settings.foreignFills[settings.host] || {} + foreignFills: settings.foreignFills[settings.origin] || {} }); let perFrameResults = await chrome.tabs.executeScript(settings.tab.id, { @@ -284,10 +286,10 @@ async function dispatchFill(settings, request, allFrames, allowForeign, allowNoS let foreignFillsChanged = false; for (let frame of perFrameResults) { if (typeof frame.foreignFill !== "undefined") { - if (typeof settings.foreignFills[settings.host] === "undefined") { - settings.foreignFills[settings.host] = {}; + if (typeof settings.foreignFills[settings.origin] === "undefined") { + settings.foreignFills[settings.origin] = {}; } - settings.foreignFills[settings.host][frame.foreignOrigin] = frame.foreignFill; + settings.foreignFills[settings.origin][frame.foreignOrigin] = frame.foreignFill; foreignFillsChanged = true; } } @@ -310,7 +312,7 @@ async function dispatchFill(settings, request, allFrames, allowForeign, allowNoS async function dispatchFocusOrSubmit(settings, request, allFrames, allowForeign) { request = Object.assign(deepCopy(request), { allowForeign: allowForeign, - foreignFills: settings.foreignFills[settings.host] || {} + foreignFills: settings.foreignFills[settings.origin] || {} }); let perFrameResults = await chrome.tabs.executeScript(settings.tab.id, { @@ -417,7 +419,7 @@ async function fillFields(settings, login, fields) { // try again using all available frames if we couldn't fill an "important" field if ( !filledFields.includes(importantFieldToFill) && - settings.foreignFills[settings.host] !== false + settings.foreignFills[settings.origin] !== false ) { allowForeign = true; filledFields = filledFields.concat( @@ -455,7 +457,7 @@ async function fillFields(settings, login, fields) { } // try again using all available frames - if (!filledFields.length && settings.foreignFills[settings.host] !== false) { + if (!filledFields.length && settings.foreignFills[settings.origin] !== false) { allowForeign = true; filledFields = filledFields.concat( await dispatchFill( @@ -581,7 +583,9 @@ async function getFullSettings() { // Fill current tab info try { settings.tab = (await chrome.tabs.query({ active: true, currentWindow: true }))[0]; - settings.host = new URL(settings.tab.url).hostname; + let originInfo = new BrowserpassURL(settings.tab.url); + settings.host = originInfo.host; // TODO remove this after OTP extension is migrated + settings.origin = originInfo.origin; } catch (e) {} return settings; @@ -772,7 +776,7 @@ async function handleMessage(settings, message, sendResponse) { case "launch": case "launchInNewTab": try { - var url = message.login.fields.url || message.login.domain; + var url = message.login.fields.url || message.login.host; if (!url) { throw new Error("No URL is defined for this entry"); } @@ -1084,6 +1088,7 @@ function triggerOTPExtension(settings, action, otp) { otp: otp, settings: { host: settings.host, + origin: settings.origin, tab: settings.tab } }) diff --git a/src/helpers.js b/src/helpers.js index 2a6d265..f30a30e 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -2,12 +2,11 @@ "use strict"; const FuzzySort = require("fuzzysort"); -const TldJS = require("tldjs"); const sha1 = require("sha1"); const ignore = require("ignore"); +const BrowserpassURL = require("@browserpass/url"); module.exports = { - pathToDomain, prepareLogins, filterSortLogins, ignoreFiles @@ -18,30 +17,30 @@ module.exports = { /** * Get the deepest available domain component of a path * - * @since 3.0.0 + * @since 3.2.3 * * @param string path Path to parse - * @param string currentHost Current hostname for the active tab - * @return string|null Extracted domain + * @param object currentHost Current host info for the active tab + * @return object|null Extracted domain info */ -function pathToDomain(path, currentHost) { +function pathToInfo(path, currentHost) { var parts = path.split(/\//).reverse(); for (var key in parts) { if (parts[key].indexOf("@") >= 0) { continue; } - var t = TldJS.parse(parts[key]); + var info = BrowserpassURL.parseHost(parts[key]); // Part is considered to be a domain component in one of the following cases: // - it is a valid domain with well-known TLD (github.com, login.github.com) - // - it is a valid domain with unknown TLD but the current host is it's subdomain (login.pi.hole) - // - it is or isnt a valid domain but the current host matches it EXACTLY (localhost, pi.hole) + // - it is or isn't a valid domain with any TLD but the current host matches it EXACTLY (localhost, pi.hole) + // - it is or isn't a valid domain with any TLD but the current host is its subdomain (login.pi.hole) if ( - t.isValid && - ((t.domain !== null && (t.tldExists || currentHost.endsWith(`.${t.hostname}`))) || - currentHost === t.hostname) + info.validDomain || + currentHost.hostname === info.hostname || + currentHost.hostname.endsWith(`.${info.hostname}`) ) { - return t.hostname; + return info; } } @@ -60,6 +59,7 @@ function pathToDomain(path, currentHost) { function prepareLogins(files, settings) { const logins = []; let index = 0; + let origin = new BrowserpassURL(settings.origin); for (let storeId in files) { for (let key in files[storeId]) { @@ -70,11 +70,40 @@ function prepareLogins(files, settings) { login: files[storeId][key].replace(/\.gpg$/i, ""), allowFill: true }; - login.domain = pathToDomain(storeId + "/" + login.login, settings.host); - login.inCurrentDomain = - settings.host == login.domain || settings.host.endsWith("." + login.domain); + + // extract url info from path + let pathInfo = pathToInfo(storeId + "/" + login.login, origin); + if (pathInfo) { + // set assumed host + login.host = pathInfo.port + ? `${pathInfo.hostname}:${pathInfo.port}` + : pathInfo.hostname; + + // check whether extracted path info matches the current origin + login.inCurrentHost = origin.hostname === pathInfo.hostname; + + // check whether the current origin is subordinate to extracted path info, meaning: + // - that the path info is not a single level domain (e.g. com, net, local) + // - and that the current origin is a subdomain of that path info + if ( + pathInfo.hostname.includes(".") && + origin.hostname.endsWith(`.${pathInfo.hostname}`) + ) { + login.inCurrentHost = true; + } + + // filter out entries with a non-matching port + if (pathInfo.port && pathInfo.port !== origin.port) { + login.inCurrentHost = false; + } + } else { + login.host = null; + login.inCurrentHost = false; + } + + // update recent counter login.recent = - settings.recent[sha1(settings.host + sha1(login.store.id + sha1(login.login)))]; + settings.recent[sha1(settings.origin + sha1(login.store.id + sha1(login.login)))]; if (!login.recent) { login.recent = { when: 0, @@ -124,7 +153,7 @@ function filterSortLogins(logins, searchQuery, currentDomainOnly) { return false; }); var remainingInCurrentDomain = candidates.filter( - login => login.inCurrentDomain && !login.recent.count + login => login.inCurrentHost && !login.recent.count ); candidates = recent.concat(remainingInCurrentDomain); } diff --git a/src/package.json b/src/package.json index 76bd83c..7125393 100644 --- a/src/package.json +++ b/src/package.json @@ -15,14 +15,14 @@ } ], "dependencies": { + "@browserpass/url": "^1.1.3", "chrome-extension-async": "^3.3.2", "fuzzysort": "^1.1.4", "idb": "^4.0.3", "ignore": "^5.1.4", "mithril": "^1.1.0", "moment": "^2.24.0", - "sha1": "^1.1.1", - "tldjs": "^2.3.0" + "sha1": "^1.1.1" }, "devDependencies": { "browserify": "^16.2.3", diff --git a/src/popup/popup.js b/src/popup/popup.js index ee926eb..5f57df3 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -47,7 +47,7 @@ async function run() { throw new Error(settings.hostError.params.message); } - if (typeof settings.host === "undefined") { + if (typeof settings.origin === "undefined") { throw new Error("Unable to retrieve current tab information"); } diff --git a/src/popup/searchinterface.js b/src/popup/searchinterface.js index 28ff5bb..96d89fa 100644 --- a/src/popup/searchinterface.js +++ b/src/popup/searchinterface.js @@ -29,6 +29,7 @@ function SearchInterface(popup) { */ function view(ctl, params) { var self = this; + var host = new URL(this.popup.settings.origin).host; return m( "form.part.search", { @@ -59,7 +60,7 @@ function view(ctl, params) { [ this.popup.currentDomainOnly ? m("div.hint.badge", [ - this.popup.settings.host, + host, m("div.remove-hint", { onclick: function(e) { var target = document.querySelector( diff --git a/src/yarn.lock b/src/yarn.lock index 82ea117..1d7c74e 100644 --- a/src/yarn.lock +++ b/src/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@browserpass/url@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@browserpass/url/-/url-1.1.3.tgz#8b94896198e7032b80c7d82e836d59e42b769669" + integrity sha512-XWhYNZo0BGcyFxkum8g1DeUkFw9QyqcaRTOEinyPSpIubh+aL2VQxbdItitVtS5qkkowqaA5Xt1H2pAqu8ic8w== + dependencies: + punycode "^2.1.1" + JSONStream@^1.0.3: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -1089,7 +1096,7 @@ punycode@^1.3.2, punycode@^1.4.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== @@ -1347,13 +1354,6 @@ timers-browserify@^1.0.1: dependencies: process "~0.11.0" -tldjs@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/tldjs/-/tldjs-2.3.1.tgz#cf09c3eb5d7403a9e214b7d65f3cf9651c0ab039" - integrity sha512-W/YVH/QczLUxVjnQhFC61Iq232NWu3TqDdO0S/MtXVz4xybejBov4ud+CIwN9aYqjOecEqIy0PscGkwpG9ZyTw== - dependencies: - punycode "^1.4.1" - to-arraybuffer@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"