diff --git a/Makefile b/Makefile index b73fae3a..e1ff0223 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ EXTENSION_FILES := \ src/popup/*.html \ src/popup/*.gif \ src/popup/*.svg \ + src/offscreen/*.html \ src/options/*.html EXTENSION_FILES := \ $(wildcard $(EXTENSION_FILES)) \ @@ -27,6 +28,7 @@ EXTENSION_FILES := \ src/css/options.dist.css \ src/js/background.dist.js \ src/js/popup.dist.js \ + src/js/offscreen.dist.js \ src/js/options.dist.js \ src/js/inject.dist.js CHROMIUM_FILES := $(patsubst src/%,chromium/%, $(EXTENSION_FILES)) diff --git a/src/Makefile b/src/Makefile index 6752479d..bbdfeb67 100644 --- a/src/Makefile +++ b/src/Makefile @@ -3,10 +3,10 @@ PRETTIER := node_modules/.bin/prettier LESSC := node_modules/.bin/lessc CLEAN_FILES := css js -PRETTIER_FILES := $(wildcard *.json *.js popup/*.js options/*.js *.less popup/*.less options/*.less *.html popup/*.html options/*.html) +PRETTIER_FILES := $(wildcard *.json *.js popup/*.js offscreen/*.js options/*.js *.less popup/*.less options/*.less *.html popup/*.html offscreen/*.html options/*.html) .PHONY: all -all: deps prettier css/popup.dist.css css/options.dist.css js/background.dist.js js/popup.dist.js js/options.dist.js js/inject.dist.js +all: deps prettier css/popup.dist.css css/options.dist.css js/background.dist.js js/popup.dist.js js/offscreen.dist.js js/options.dist.js js/inject.dist.js .PHONY: deps deps: @@ -32,6 +32,10 @@ js/popup.dist.js: $(BROWSERIFY) popup/*.js helpers.js [ -d js ] || mkdir -p js $(BROWSERIFY) -o js/popup.dist.js popup/popup.js +js/offscreen.dist.js: $(BROWSERIFY) offscreen/*.js + [ -d js ] || mkdir -p js + $(BROWSERIFY) -o js/offscreen.dist.js offscreen/offscreen.js + js/options.dist.js: $(BROWSERIFY) options/*.js [ -d js ] || mkdir -p js $(BROWSERIFY) -o js/options.dist.js options/options.js diff --git a/src/background.js b/src/background.js index b97a1b96..d44e5e64 100644 --- a/src/background.js +++ b/src/background.js @@ -39,7 +39,7 @@ var badgeCache = { // the last text copied to the clipboard is stored here in order to be cleared after 60 seconds let lastCopiedText = null; -chrome.browserAction.setBadgeBackgroundColor({ +chrome.action.setBadgeBackgroundColor({ color: "#666", }); @@ -89,13 +89,91 @@ chrome.commands.onCommand.addListener(async (command) => { } }); +let currentAuthRequest = null; + +function resolveAuthRequest(message, senderUrl) { + if (currentAuthRequest) { + if (new URL(senderUrl).href.startsWith(new URL(currentAuthRequest.url).href)) { + console.info("Resolve current auth request", senderUrl); + currentAuthRequest.resolve(message); + currentAuthRequest = null; + } + } else { + console.warn("Resolve auth request received without existing details", senderUrl); + } +} + +async function createAuthRequestModal(url, callback, details) { + // https://developer.chrome.com/docs/extensions/reference/api/windows + const popup = await chrome.windows.create({ + url: url, + width: 450, + left: 450, + height: 300, + top: 300, + type: "popup", + focused: true, + }); + + function onPopupClose(windowId) { + const waitingRequestId = + (currentAuthRequest && currentAuthRequest.popup && currentAuthRequest.popup.id) || + false; + if (waitingRequestId === windowId) { + chrome.alarms.create("clearAuthRequest", { when: Date.now() + 1e3 }); + } + } + currentAuthRequest = { resolve: callback, url, details, popup }; + chrome.windows.onRemoved.addListener(onPopupClose); +} + +chrome.webRequest.onAuthRequired.addListener( + function (details, chromeOnlyAsyncCallback) { + const url = + `${helpers.getPopupUrl()}` + + `?${helpers.AUTH_URL_QUERY_PARAM}=${encodeURIComponent(details.url)}`; + + return new Promise((resolvePromise, _) => { + const resolve = chromeOnlyAsyncCallback || resolvePromise; + if (currentAuthRequest) { + console.warn("Another auth request is already in progress"); + resolve({}); + } else { + createAuthRequestModal(url, resolve, details); + } + }); + }, + { urls: [""] }, + helpers.isChrome() ? ["asyncBlocking"] : ["blocking"] +); + +/** + * ensure service worker remains awake till clipboard is cleared + * + * @since 3.10.0 + */ +async function keepAlive() { + chrome.alarms.create("keepAlive", { when: Date.now() + 25e3 }); + await getFullSettings(); +} + // handle fired alarms -chrome.alarms.onAlarm.addListener((alarm) => { +chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === "clearClipboard") { - if (readFromClipboard() === lastCopiedText) { + if ((await readFromClipboard()) === lastCopiedText) { copyToClipboard("", false); } lastCopiedText = null; + } else if (alarm.name === "keepAlive") { + const current = await readFromClipboard(); + // stop if either value changes + if (current === lastCopiedText) { + await keepAlive(); + } + } else if (alarm.name === "clearAuthRequest") { + if (currentAuthRequest !== null) { + resolveAuthRequest({ cancel: true }, currentAuthRequest.url); + } } }); @@ -156,7 +234,7 @@ async function updateMatchingPasswordsCount(tabId, forceRefresh = false) { ); // Set badge for the current tab - chrome.browserAction.setBadgeText({ + chrome.action.setBadgeText({ text: "" + (matchedPasswordsCount || ""), tabId: tabId, }); @@ -175,20 +253,30 @@ async function updateMatchingPasswordsCount(tabId, forceRefresh = false) { * @param boolean clear Whether to clear the clipboard after one minute * @return void */ -function copyToClipboard(text, clear = true) { - document.addEventListener( - "copy", - function (e) { - e.clipboardData.setData("text/plain", text); - e.preventDefault(); - }, - { once: true } - ); - document.execCommand("copy"); +async function copyToClipboard(text, clear = true) { + if (helpers.isChrome()) { + await setupOffscreenDocument("offscreen/offscreen.html"); + chrome.runtime.sendMessage({ + type: "copy-data-to-clipboard", + target: "offscreen-doc", + data: text, + }); + } else { + document.addEventListener( + "copy", + function (e) { + e.clipboardData.setData("text/plain", text); + e.preventDefault(); + }, + { once: true } + ); + document.execCommand("copy"); + } if (clear) { lastCopiedText = text; chrome.alarms.create("clearClipboard", { delayInMinutes: 1 }); + await keepAlive(); } } @@ -199,17 +287,70 @@ function copyToClipboard(text, clear = true) { * * @return string The current plaintext content of the clipboard */ -function readFromClipboard() { - const ta = document.createElement("textarea"); - // these lines are carefully crafted to make paste work in both Chrome and Firefox - ta.contentEditable = true; - ta.textContent = ""; - document.body.appendChild(ta); - ta.select(); - document.execCommand("paste"); - const content = ta.value; - document.body.removeChild(ta); - return content; +async function readFromClipboard() { + if (helpers.isChrome()) { + await setupOffscreenDocument("offscreen/offscreen.html"); + + const response = await chrome.runtime.sendMessage({ + type: "read-from-clipboard", + target: "offscreen-doc", + }); + + if (response.status != "ok") { + console.error( + "failure reading from clipboard in offscreen document", + response.message || undefined + ); + return; + } + + return response.message; + } else { + const ta = document.createElement("textarea"); + // these lines are carefully crafted to make paste work in both Chrome and Firefox + ta.contentEditable = true; + ta.textContent = ""; + document.body.appendChild(ta); + ta.select(); + document.execCommand("paste"); + const content = ta.value; + document.body.removeChild(ta); + return content; + } +} + +/** + * Setup offscreen document + * @since 3.10.0 + * @param string path - location of html document to be created + */ +let creatingOffscreen; // A global promise to avoid concurrency issues +async function setupOffscreenDocument(path) { + // Check all windows controlled by the service worker to see if one + // of them is the offscreen document with the given path + const offscreenUrl = chrome.runtime.getURL(path); + const existingContexts = await chrome.runtime.getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT"], + documentUrls: [offscreenUrl], + }); + + if (existingContexts.length > 0) { + return; + } + + // create offscreen document + if (!creatingOffscreen) { + creatingOffscreen = chrome.offscreen.createDocument({ + url: path, + reasons: [chrome.offscreen.Reason.CLIPBOARD], + justification: "Read / write text to the clipboard", + }); + } + + if (creatingOffscreen) { + await creatingOffscreen; + creatingOffscreen = null; + } } /** @@ -227,7 +368,9 @@ async function saveRecent(settings, login, remove = false) { var ignoreInterval = 60000; // 60 seconds - don't increment counter twice within this window // save store timestamp - localStorage.setItem("recent:" + login.store.id, JSON.stringify(Date.now())); + const obj = {}; + obj[`recent:${login.store.id}`] = JSON.stringify(Date.now()); + chrome.storage.local.set(obj); // update login usage count & timestamp if (Date.now() > login.recent.when + ignoreInterval) { @@ -238,7 +381,7 @@ async function saveRecent(settings, login, remove = false) { login.recent; // save to local storage - localStorage.setItem("recent", JSON.stringify(settings.recent)); + chrome.storage.local.set({ recent: JSON.stringify(settings.recent) }); // a new entry was added to the popup matching list, need to refresh the count if (!login.inCurrentHost && login.recent.count === 1) { @@ -276,15 +419,18 @@ async function dispatchFill(settings, request, allFrames, allowForeign, allowNoS foreignFills: settings.foreignFills[settings.origin] || {}, }); - let perFrameResults = await chrome.tabs.executeScript(settings.tab.id, { - allFrames: allFrames, - code: `window.browserpass.fillLogin(${JSON.stringify(request)});`, + let perFrameResults = await chrome.scripting.executeScript({ + target: { tabId: settings.tab.id, allFrames: allFrames }, + func: function (request) { + return window.browserpass.fillLogin(request); + }, + args: [request], }); // merge filled fields into a single array let filledFields = perFrameResults - .reduce((merged, frameResult) => merged.concat(frameResult.filledFields), []) - .filter((val, i, merged) => merged.indexOf(val) === i); + .reduce((merged, frameResult) => merged.concat(frameResult.result.filledFields), []) + .filter((val, i, merged) => val && merged.indexOf(val) === i); // if user answered a foreign-origin confirmation, // store the answers in the settings @@ -320,9 +466,12 @@ async function dispatchFocusOrSubmit(settings, request, allFrames, allowForeign) foreignFills: settings.foreignFills[settings.origin] || {}, }); - await chrome.tabs.executeScript(settings.tab.id, { - allFrames: allFrames, - code: `window.browserpass.focusOrSubmit(${JSON.stringify(request)});`, + await chrome.scripting.executeScript({ + target: { tabId: settings.tab.id, allFrames: allFrames }, + func: function (request) { + window.browserpass.focusOrSubmit(request); + }, + args: [request], }); } @@ -338,9 +487,9 @@ async function injectScript(settings, allFrames) { return new Promise(async (resolve, reject) => { const waitTimeout = setTimeout(reject, MAX_WAIT); - await chrome.tabs.executeScript(settings.tab.id, { - allFrames: allFrames, - file: "js/inject.dist.js", + await chrome.scripting.executeScript({ + target: { tabId: settings.tab.id, allFrames: allFrames }, + files: ["js/inject.dist.js"], }); clearTimeout(waitTimeout); resolve(true); @@ -478,12 +627,29 @@ async function fillFields(settings, login, fields) { * * @return object Local settings from the extension */ -function getLocalSettings() { +async function getLocalSettings() { var settings = helpers.deepCopy(defaultSettings); - for (var key in settings) { - var value = localStorage.getItem(key); - if (value !== null) { - settings[key] = JSON.parse(value); + + try { + // use for debugging only, since dev tools does not show extension storage + await chrome.storage.local.get(console.dir); + } catch (err) { + console.warn("could not display extension local storage"); + } + + var items = await chrome.storage.local.get(Object.keys(defaultSettings)); + for (var key in defaultSettings) { + var value = null; + if (Object.prototype.hasOwnProperty.call(items, key)) { + value = items[key]; + } + + if (value !== null && Boolean(value)) { + try { + settings[key] = value; + } catch (err) { + console.error(`getLocalSettings(), error JSON.parse(value):`, err, { key, value }); + } } } @@ -498,11 +664,12 @@ function getLocalSettings() { * @return object Full settings object */ async function getFullSettings() { - var settings = getLocalSettings(); + var settings = await getLocalSettings(); var configureSettings = Object.assign(helpers.deepCopy(settings), { defaultStore: {}, }); var response = await hostAction(configureSettings, "configure"); + if (response.status != "ok") { settings.hostError = response; } @@ -554,16 +721,30 @@ async function getFullSettings() { // Fill recent data for (var storeId in settings.stores) { - var when = localStorage.getItem("recent:" + storeId); - if (when) { - settings.stores[storeId].when = JSON.parse(when); + const whenKey = `recent:${storeId}`; + var when = await chrome.storage.local.get([whenKey]); + if (when && Object.prototype.hasOwnProperty.call(when, whenKey)) { + try { + settings.stores[storeId].when = JSON.parse(when[whenKey]); + } catch (err) { + console.error( + `getFullSettings() error fill stores recent data (${whenKey})`, + err, + when + ); + } } else { settings.stores[storeId].when = 0; } } - settings.recent = localStorage.getItem("recent"); - if (settings.recent) { - settings.recent = JSON.parse(settings.recent); + const recentKey = "recent"; + const recent = await chrome.storage.local.get(recentKey); + if (recent && Object.prototype.hasOwnProperty.call(recent, recentKey)) { + try { + settings.recent = JSON.parse(recent[recentKey]); + } catch (err) { + console.error(`getFullSettings() error recent`, err, recent); + } } else { settings.recent = {}; } @@ -571,92 +752,29 @@ async function getFullSettings() { // Fill current tab info try { settings.tab = (await chrome.tabs.query({ active: true, currentWindow: true }))[0]; - let originInfo = new BrowserpassURL(settings.tab.url); - settings.origin = originInfo.origin; - } catch (e) {} - - return settings; -} - -/** - * Get most relevant setting value - * - * @param string key Setting key - * @param object login Login object - * @param object settings Settings object - * @return object Setting value - */ -function getSetting(key, login, settings) { - if (typeof login.settings[key] !== "undefined") { - return login.settings[key]; - } - if (typeof settings.stores[login.store.id].settings[key] !== "undefined") { - return settings.stores[login.store.id].settings[key]; - } - - return settings[key]; -} - -/** - * Handle modal authentication requests (e.g. HTTP basic) - * - * @since 3.0.0 - * - * @param object requestDetails Auth request details - * @return object Authentication credentials or {} - */ -function handleModalAuth(requestDetails) { - var launchHost = requestDetails.url.match(/:\/\/([^\/]+)/)[1]; - - // don't attempt authentication against the same login more than once - if (!this.login.allowFill) { - return {}; - } - this.login.allowFill = false; - - // don't attempt authentication outside the main frame - if (requestDetails.type !== "main_frame") { - return {}; - } - - // ensure the auth domain is the same, or ask the user for permissions to continue - if (launchHost !== requestDetails.challenger.host) { - var message = - "You are about to send login credentials to a domain that is different than " + - "the one you launched from the browserpass extension. Do you wish to proceed?\n\n" + - "Realm: " + - requestDetails.realm + - "\n" + - "Launched URL: " + - this.url + - "\n" + - "Authentication URL: " + - requestDetails.url; - if (!confirm(message)) { - return {}; + if (settings.tab) { + let originInfo = new BrowserpassURL(settings.tab.url); + settings.origin = originInfo.origin; } + } catch (e) { + console.error(`getFullsettings() failure getting tab: ${e}`, { e }); } - // ask the user before sending credentials over an insecure connection - if (!requestDetails.url.match(/^https:/i)) { - var message = - "You are about to send login credentials via an insecure connection!\n\n" + - "Are you sure you want to do this? If there is an attacker watching your " + - "network traffic, they may be able to see your username and password.\n\n" + - "URL: " + - requestDetails.url; - if (!confirm(message)) { - return {}; + // check for auth url + try { + if (settings.tab) { + const authUrl = helpers.parseAuthUrl(settings.tab.url); + if (authUrl && currentAuthRequest && currentAuthRequest.url) { + settings.authRequested = authUrl.startsWith( + helpers.parseAuthUrl(currentAuthRequest.url) + ); + } } + } catch (e) { + console.error(`getFullsettings() failure parsing auth url: ${e}`, { e }); } - // supply credentials - return { - authCredentials: { - username: this.login.fields.login, - password: this.login.fields.secret, - }, - }; + return settings; } /** @@ -781,7 +899,7 @@ async function handleMessage(settings, message, sendResponse) { break; case "copyPassword": try { - copyToClipboard(message.login.fields.secret); + await copyToClipboard(message.login.fields.secret); await saveRecent(settings, message.login); sendResponse({ status: "ok" }); } catch (e) { @@ -793,7 +911,7 @@ async function handleMessage(settings, message, sendResponse) { break; case "copyUsername": try { - copyToClipboard(message.login.fields.login); + await copyToClipboard(message.login.fields.login); await saveRecent(settings, message.login); sendResponse({ status: "ok" }); } catch (e) { @@ -809,7 +927,7 @@ async function handleMessage(settings, message, sendResponse) { if (!message.login.fields.otp) { throw new Exception("No OTP seed available"); } - copyToClipboard(helpers.makeTOTP(message.login.fields.otp.params)); + await copyToClipboard(helpers.makeTOTP(message.login.fields.otp.params)); sendResponse({ status: "ok" }); } catch (e) { sendResponse({ @@ -842,19 +960,6 @@ async function handleMessage(settings, message, sendResponse) { ? await chrome.tabs.update(settings.tab.id, { url: url }) : await chrome.tabs.create({ url: url }); - if (authListeners[tab.id]) { - chrome.tabs.onUpdated.removeListener(authListeners[tab.id]); - delete authListeners[tab.id]; - } - authListeners[tab.id] = handleModalAuth.bind({ - url: url, - login: message.login, - }); - chrome.webRequest.onAuthRequired.addListener( - authListeners[tab.id], - { urls: ["*://*/*"], tabId: tab.id }, - ["blocking"] - ); sendResponse({ status: "ok" }); } catch (e) { sendResponse({ @@ -867,9 +972,24 @@ async function handleMessage(settings, message, sendResponse) { try { let fields = message.login.fields.openid ? ["openid"] : ["login", "secret"]; - // dispatch initial fill request - var filledFields = await fillFields(settings, message.login, fields); - await saveRecent(settings, message.login); + if (settings.authRequested) { + resolveAuthRequest( + { + authCredentials: { + username: message.login.fields.login, + password: message.login.fields.secret, + }, + }, + settings.tab.url + ); + await saveRecent(settings, message.login); + sendResponse({ status: "ok" }); + break; + } else { + // dispatch initial fill request + var filledFields = await fillFields(settings, message.login, fields); + await saveRecent(settings, message.login); + } // no need to check filledFields, because fillFields() already throws an error if empty sendResponse({ status: "ok", filledFields: filledFields }); @@ -880,7 +1000,7 @@ async function handleMessage(settings, message, sendResponse) { helpers.getSetting("enableOTP", message.login, settings) && message.login.fields.hasOwnProperty("otp") ) { - copyToClipboard(helpers.makeTOTP(message.login.fields.otp.params)); + await copyToClipboard(helpers.makeTOTP(message.login.fields.otp.params)); } } catch (e) { try { @@ -1113,11 +1233,11 @@ async function receiveMessage(message, sender, sendResponse) { */ async function clearUsageData() { // clear local storage - localStorage.removeItem("foreignFills"); - localStorage.removeItem("recent"); - Object.keys(localStorage).forEach((key) => { + chrome.storage.local.remove("foreignFills"); + chrome.storage.local.remove("recent"); + Object.keys(chrome.storage.local.getKeys()).forEach((key) => { if (key.startsWith("recent:")) { - localStorage.removeItem(key); + chrome.storage.local.remove(key); } }); @@ -1154,7 +1274,9 @@ async function saveSettings(settings) { for (var key in defaultSettings) { if (settingsToSave.hasOwnProperty(key)) { - localStorage.setItem(key, JSON.stringify(settingsToSave[key])); + const save = {}; + save[key] = settingsToSave[key]; + await chrome.storage.local.set(save); } } @@ -1186,8 +1308,8 @@ function onExtensionInstalled(details) { }; if (details.reason === "install") { - if (localStorage.getItem("installed") === null) { - localStorage.setItem("installed", Date.now()); + if (chrome.storage.local.get("installed") === null) { + chrome.storage.local.set({ installed: Date.now() }); show( "installed", "browserpass: Install native host app", diff --git a/src/helpers.js b/src/helpers.js index 9efddaed..50304746 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -5,8 +5,6 @@ const FuzzySort = require("fuzzysort"); const sha1 = require("sha1"); const ignore = require("ignore"); const hash = require("hash.js"); -const m = require("mithril"); -const notify = require("./popup/notifications"); const Authenticator = require("otplib").authenticator.Authenticator; const BrowserpassURL = require("@browserpass/url"); @@ -18,24 +16,28 @@ const fieldsPrefix = { url: ["url", "uri", "website", "site", "link", "launch"], }; -const containsNumbersRegEx = RegExp(/[0-9]/); const containsSymbolsRegEx = RegExp(/[\p{P}\p{S}]/, "u"); const LATEST_NATIVE_APP_VERSION = 3001000; +const AUTH_URL_QUERY_PARAM = "authUrl"; +const LAUNCH_URL_DEPRECATION_MESSAGE = `

Deprecation

"Ctrl+G" and "Ctrl+Shift+G" shortcuts are deprecated and will be removed in a future version.

It is no longer necessary to open websites which require basic auth using these shortcuts. Navigate websites normally and Browserpass will automatically open a popup for you to choose the credentials.

`; module.exports = { containsSymbolsRegEx, fieldsPrefix, + AUTH_URL_QUERY_PARAM, LATEST_NATIVE_APP_VERSION, + LAUNCH_URL_DEPRECATION_MESSAGE, deepCopy, filterSortLogins, - handleError, - highlight, + getPopupUrl, getSetting, ignoreFiles, + isChrome, makeTOTP, + parseAuthUrl, prepareLogin, prepareLogins, - withLogin, + unsecureRequestWarning, }; //----------------------------------- Function definitions ----------------------------------// @@ -56,90 +58,14 @@ function deepCopy(obj) { } /** - * Handle an error + * Returns url string of the html popup page + * @since 3.10.0 * - * @since 3.0.0 - * - * @param Error error Error object - * @param string type Error type + * @returns string */ -function handleError(error, type = "error") { - switch (type) { - case "error": - console.log(error); - // disable error timeout, to allow necessary user action - notify.errorMsg(error.toString(), 0); - break; - - case "warning": - notify.warningMsg(error.toString()); - break; - - case "success": - notify.successMsg(error.toString()); - break; - - case "info": - default: - notify.infoMsg(error.toString()); - break; - } -} - -/** - * Do a login action - * - * @since 3.0.0 - * - * @param string action Action to take - * @param object params Action parameters - * @return void - */ -async function withLogin(action, params = {}) { - try { - switch (action) { - case "fill": - handleError("Filling login details...", "info"); - break; - case "launch": - handleError("Launching URL...", "info"); - break; - case "launchInNewTab": - handleError("Launching URL in a new tab...", "info"); - break; - case "copyPassword": - handleError("Copying password to clipboard...", "info"); - break; - case "copyUsername": - handleError("Copying username to clipboard...", "info"); - break; - case "copyOTP": - handleError("Copying OTP token to clipboard...", "info"); - break; - default: - handleError("Please wait...", "info"); - break; - } - - const login = deepCopy(this.login); - - // hand off action to background script - var response = await chrome.runtime.sendMessage({ action, login, params }); - if (response.status != "ok") { - throw new Error(response.message); - } else { - if (response.login && typeof response.login === "object") { - response.login.doAction = withLogin.bind({ - settings: this.settings, - login: response.login, - }); - } else { - window.close(); - } - } - } catch (e) { - handleError(e); - } +function getPopupUrl() { + const base = chrome || browser; + return base.runtime.getURL("popup/popup.html"); } /* @@ -161,6 +87,32 @@ function getSetting(key, login, settings) { return settings[key]; } +/** + * returns true if agent string is Chrome / Chromium + * + * @since 3.10.0 + */ +function isChrome() { + return chrome.runtime.getURL("/").startsWith("chrom"); + /** + * Alternate approach to checking if current browser is chrome or + * chromium based. + * + * @TODO: remove one of these two after probationary period + * to determine which will approach will best suite browserpass + * purposes in the wild. + * + * Above: .getURL("/") will error on "chrome://" protocols + * Below: check user agent, can be altered depending vendor + */ + // const ua = navigator.userAgent; + // const matches = ua.match(/(chrom)/i) || []; + // if (Object.keys(matches).length > 2 && /chrom/i.test(matches[1])) { + // return true; + // } + // return false; +} + /** * Get the deepest available domain component of a path * @@ -194,6 +146,25 @@ function pathToInfo(path, currentHost) { return null; } +/** + * Returns decoded url param for "authUrl" if present + * @since 3.10.0 + * @param string url string to parse and compare against extension popup url + * @returns string | null + */ +function parseAuthUrl(url) { + const currentUrl = url || null; + + // query url not exact match when includes fragments, so must start with extension url + if (currentUrl && `${currentUrl}`.startsWith(getPopupUrl())) { + const encodedUrl = new URL(currentUrl).searchParams.get(AUTH_URL_QUERY_PARAM); + if (encodedUrl) { + return decodeURIComponent(encodedUrl); + } + } + return null; +} + /** * Prepare list of logins based on provided files * @@ -206,7 +177,9 @@ function pathToInfo(path, currentHost) { function prepareLogins(files, settings) { const logins = []; let index = 0; - let origin = new BrowserpassURL(settings.origin); + let origin = new BrowserpassURL( + settings.authRequested ? parseAuthUrl(settings.tab.url) : settings.origin + ); for (let storeId in files) { for (let key in files[storeId]) { @@ -284,25 +257,6 @@ function prepareLogin(settings, storeId, file, index = 0, origin = undefined) { return login; } -/** - * Highlight password characters - * - * @since 3.8.0 - * - * @param {string} secret a string to be split by character - * @return {array} mithril vnodes to be rendered - */ -function highlight(secret = "") { - return secret.split("").map((c) => { - if (c.match(containsNumbersRegEx)) { - return m("span.char.num", c); - } else if (c.match(containsSymbolsRegEx)) { - return m("span.char.punct", c); - } - return m("span.char", c); - }); -} - /** * Filter and sort logins * @@ -552,3 +506,16 @@ function sortUnique(array, comparator) { .sort(comparator) .filter((elem, index, arr) => index == !arr.length || arr[index - 1] != elem); } + +/** + * Returns warning html string with unsecure url specified + * @returns html string + */ +function unsecureRequestWarning(url) { + return ( + "

Warning: Are you sure you want to do this?

" + + "

You are about to send login credentials via an insecure connection!

" + + "

If there is an attacker watching your network traffic, they may be able to see your username and password.

" + + `

URL: ${url}

` + ); +} diff --git a/src/helpers.redraw.js b/src/helpers.redraw.js new file mode 100644 index 00000000..8fdd08db --- /dev/null +++ b/src/helpers.redraw.js @@ -0,0 +1,35 @@ +"use strict"; + +const m = require("mithril"); + +module.exports = { + increaseModalHeight, +}; + +/** + * Increases modal window height using a document child reference height + * Maximum height is 1000px + * @param referenceElement an htmlElement returned from one of document.getElementBy... methods + * @since 3.10.0 + */ +function increaseModalHeight(referenceElement, heightDiff = 50) { + if (typeof heightDiff != "number") { + heightDiff = 50; + } + if (!referenceElement) { + return; + } + const rootContentEl = document.getRootNode().getElementsByClassName("layout")[0] ?? null; + if (rootContentEl) { + let count = 0; + while ( + rootContentEl.clientHeight < 1000 && + rootContentEl.clientHeight < referenceElement?.clientHeight + heightDiff + ) { + rootContentEl.classList.remove(...rootContentEl.classList); + rootContentEl.classList.add(...["layout", `mh-${count}`]); + m.redraw(); + count += 1; + } + } +} diff --git a/src/helpers.ui.js b/src/helpers.ui.js new file mode 100644 index 00000000..1fa18889 --- /dev/null +++ b/src/helpers.ui.js @@ -0,0 +1,154 @@ +//------------------------------------- Initialisation --------------------------------------// +"use strict"; + +const m = require("mithril"); +const dialog = require("./popup/modalDialog"); +const notify = require("./popup/notifications"); +const helpers = require("./helpers"); + +const containsNumbersRegEx = RegExp(/[0-9]/); +const containsSymbolsRegEx = RegExp(/[\p{P}\p{S}]/, "u"); + +module.exports = { + handleError, + highlight, + withLogin, +}; + +//----------------------------------- Function definitions ----------------------------------// + +/** + * Highlight password characters + * + * @since 3.8.0 + * + * @param {string} secret a string to be split by character + * @return {array} mithril vnodes to be rendered + */ +function highlight(secret = "") { + return secret.split("").map((c) => { + if (c.match(containsNumbersRegEx)) { + return m("span.char.num", c); + } else if (c.match(containsSymbolsRegEx)) { + return m("span.char.punct", c); + } + return m("span.char", c); + }); +} + +/** + * Handle an error + * + * @since 3.0.0 + * + * @param Error error Error object + * @param string type Error type + */ +function handleError(error, type = "error") { + switch (type) { + case "error": + console.error(error); + // disable error timeout, to allow necessary user action + notify.errorMsg(error.toString(), 0); + break; + + case "warning": + notify.warningMsg(error.toString()); + console.warn(error.toString()); + break; + + case "success": + notify.successMsg(error.toString()); + console.info(error.toString()); + break; + + case "info": + default: + notify.infoMsg(error.toString()); + console.info(error.toString()); + break; + } +} + +/** + * Do a login action + * + * @since 3.0.0 + * + * @param string action Action to take + * @param object params Action parameters + * @return void + */ +async function withLogin(action, params = {}) { + try { + const url = helpers.parseAuthUrl(this.settings?.tab?.url ?? null); + const askToProceed = + action === "fill" && + this.settings?.authRequested && + !params?.confirmedAlready && + !url?.match(/^https:/i); + if (askToProceed) { + const that = this; + that.doAction = withLogin.bind({ + settings: this.settings, + login: this.login, + }); + dialog.open( + { message: helpers.unsecureRequestWarning(url), type: "warning" }, + function () { + // proceed + params.confirmedAlready = true; + that.doAction(action, params); + } + ); + return; + } + } catch (e) { + console.error(e); + } + + try { + switch (action) { + case "fill": + handleError("Filling login details...", "info"); + break; + case "launch": + handleError("Launching URL...", "info"); + break; + case "launchInNewTab": + handleError("Launching URL in a new tab...", "info"); + break; + case "copyPassword": + handleError("Copying password to clipboard...", "info"); + break; + case "copyUsername": + handleError("Copying username to clipboard...", "info"); + break; + case "copyOTP": + handleError("Copying OTP token to clipboard...", "info"); + break; + default: + handleError("Please wait...", "info"); + break; + } + + const login = helpers.deepCopy(this.login); + + // hand off action to background script + var response = await chrome.runtime.sendMessage({ action, login, params }); + if (response.status != "ok") { + throw new Error(response.message); + } else { + if (response.login && typeof response.login === "object") { + response.login.doAction = withLogin.bind({ + settings: this.settings, + login: response.login, + }); + } else { + window.close(); + } + } + } catch (e) { + handleError(e); + } +} diff --git a/src/manifest-chromium.json b/src/manifest-chromium.json index 2bc21aed..20584b16 100644 --- a/src/manifest-chromium.json +++ b/src/manifest-chromium.json @@ -1,20 +1,19 @@ { "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvlVUvevBvdeIFpvK5Xjcbd/cV8AsMNLg0Y7BmUetSTagjts949Tp12mNmWmIEEaE9Zwmfjl1ownWiclGhsoPSf6x7nP/i0j8yROv6TYibXLhZet9y4vnUMgtCIkb3O5RnuOl0Y+V3XUADwxotmgT1laPUThymJoYnWPv+lwDkYiEopX2Aq2amzRj8aMogNBUbAIkCMxfa9WK3Vm0QTAUdV4ii9WqzbgjHVruQpiFVq99W2U9ddsWNZjOG/36sFREuHw+reulQgblp9FZdaN1Q9X5cGcT5bncQIRB6K3wZYa805gFENc93Wslmzu6aUSEKqqPymlI5ikedaPlXPmlqwIDAQAB", - "manifest_version": 2, + "manifest_version": 3, "name": "Browserpass", "description": "Browser extension for zx2c4's pass (password manager)", "version": "3.9.0", "author": "Maxim Baz , Steve Gilberd ", "homepage_url": "https://github.com/browserpass/browserpass-extension", "background": { - "persistent": true, - "scripts": ["js/background.dist.js"] + "service_worker": "js/background.dist.js" }, "icons": { "16": "icon16.png", "128": "icon.png" }, - "browser_action": { + "action": { "default_icon": { "16": "icon16.png", "128": "icon.png" @@ -23,7 +22,6 @@ }, "options_ui": { "page": "options/options.html", - "chrome_style": true, "open_in_tab": false }, "permissions": [ @@ -34,14 +32,18 @@ "clipboardWrite", "nativeMessaging", "notifications", + "offscreen", + "scripting", + "storage", "webRequest", - "webRequestBlocking", - "http://*/*", - "https://*/*" + "webRequestAuthProvider" ], - "content_security_policy": "default-src 'none'; font-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'", + "host_permissions": ["http://*/*", "https://*/*"], + "content_security_policy": { + "extension_pages": "default-src 'none'; font-src 'self'; img-src 'self' data:; script-src 'self'" + }, "commands": { - "_execute_browser_action": { + "_execute_action": { "suggested_key": { "default": "Ctrl+Shift+L" } diff --git a/src/manifest-firefox.json b/src/manifest-firefox.json index 6b2c198f..cbe4a328 100644 --- a/src/manifest-firefox.json +++ b/src/manifest-firefox.json @@ -1,19 +1,18 @@ { - "manifest_version": 2, + "manifest_version": 3, "name": "Browserpass", "description": "Browser extension for zx2c4's pass (password manager)", "version": "3.9.0", "author": "Maxim Baz , Steve Gilberd ", "homepage_url": "https://github.com/browserpass/browserpass-extension", "background": { - "persistent": true, "scripts": ["js/background.dist.js"] }, "icons": { "16": "icon16.png", "128": "icon.png" }, - "browser_action": { + "action": { "default_icon": { "16": "icon16.png", "128": "icon.svg" @@ -32,20 +31,23 @@ "clipboardWrite", "nativeMessaging", "notifications", + "scripting", + "storage", "webRequest", - "webRequestBlocking", - "http://*/*", - "https://*/*" + "webRequestAuthProvider" ], - "content_security_policy": "default-src 'none'; font-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'", - "applications": { + "host_permissions": ["http://*/*", "https://*/*"], + "content_security_policy": { + "extension_pages": "default-src 'none'; font-src 'self'; img-src 'self' data:; script-src 'self'" + }, + "browser_specific_settings": { "gecko": { "id": "browserpass@maximbaz.com", "strict_min_version": "58.0" } }, "commands": { - "_execute_browser_action": { + "_execute_action": { "suggested_key": { "default": "Ctrl+Shift+L" } diff --git a/src/offscreen/offscreen.html b/src/offscreen/offscreen.html new file mode 100644 index 00000000..5e47e2a6 --- /dev/null +++ b/src/offscreen/offscreen.html @@ -0,0 +1,3 @@ + + + diff --git a/src/offscreen/offscreen.js b/src/offscreen/offscreen.js new file mode 100644 index 00000000..349b980c --- /dev/null +++ b/src/offscreen/offscreen.js @@ -0,0 +1,79 @@ +//------------------------------------- Initialization --------------------------------------// +"use strict"; + +//----------------------------------- Function definitions ----------------------------------// +chrome.runtime.onMessage.addListener(handleMessage); + +async function handleMessage(message, sender, sendResponse) { + if (sender.id !== chrome.runtime.id) { + // silently exit without responding when the source is foreign + return; + } + + // Return early if this message isn't meant for the offscreen document. + if (message.target !== "offscreen-doc") { + return; + } + + // Dispatch the message to an appropriate handler. + let reply; + try { + switch (message.type) { + case "copy-data-to-clipboard": + writeToClipboard(message.data); + break; + case "read-from-clipboard": + reply = readFromClipboard(); + break; + default: + console.warn(`Unexpected message type received: '${message.type}'.`); + } + sendResponse({ status: "ok", message: reply || undefined }); + } catch (e) { + sendResponse({ status: "error", message: e.toString() }); + } +} + +/** + * Read plain text from clipboard + * + * @since 3.2.0 + * + * @return string The current plaintext content of the clipboard + */ +function readFromClipboard() { + const ta = document.querySelector("#text"); + // these lines are carefully crafted to make paste work in both Chrome and Firefox + ta.contentEditable = true; + ta.textContent = ""; + ta.select(); + document.execCommand("paste"); + const content = ta.value; + return content; +} + +/** + * Copy text to clipboard and optionally clear it from the clipboard after one minute + * + * @since 3.2.0 + * + * @param string text Text to copy + * @param boolean clear Whether to clear the clipboard after one minute + * @return void + */ +async function writeToClipboard(text) { + // Error if we received the wrong kind of data. + if (typeof text !== "string") { + throw new TypeError(`Value provided must be a 'string', got '${typeof text}'.`); + } + + document.addEventListener( + "copy", + function (e) { + e.clipboardData.setData("text/plain", text); + e.preventDefault(); + }, + { once: true } + ); + document.execCommand("copy"); +} diff --git a/src/popup/addEditInterface.js b/src/popup/addEditInterface.js index 0595f807..dca5d188 100644 --- a/src/popup/addEditInterface.js +++ b/src/popup/addEditInterface.js @@ -3,9 +3,10 @@ const Login = require("./models/Login"); const Settings = require("./models/Settings"); const Tree = require("./models/Tree"); const notify = require("./notifications"); +const dialog = require("./modalDialog"); const helpers = require("../helpers"); +const helpersUI = require("../helpers.ui"); const layout = require("./layoutInterface"); -const dialog = require("./modalDialog"); module.exports = AddEditInterface; @@ -486,7 +487,7 @@ function AddEditInterface(settingsModel) { m( "div.chars", loginObj.hasOwnProperty("fields") - ? helpers.highlight(loginObj.fields.secret) + ? helpersUI.highlight(loginObj.fields.secret) : "" ), m("div.btn.generate", { diff --git a/src/popup/colors.less b/src/popup/colors.less index 53cee572..c5e41224 100644 --- a/src/popup/colors.less +++ b/src/popup/colors.less @@ -265,5 +265,25 @@ background-color: @ntfy-warning-bgcolor; border: 1px solid @ntfy-warning-border; } + + &.warning { + color: @ntfy-warning-color; + background-color: @ntfy-warning-bgcolor; + border: 1px solid @ntfy-warning-border; + } + &.error { + color: @ntfy-error-color; + background-color: @ntfy-error-bgcolor; + border: 1px solid @ntfy-error-border; + } + + &.warning, + &.error { + button { + color: @ntfy-info-color; + background-color: @ntfy-info-bgcolor; + border: 1px solid @ntfy-info-border; + } + } } } diff --git a/src/popup/detailsInterface.js b/src/popup/detailsInterface.js index 23ec93cd..5ca2ad27 100644 --- a/src/popup/detailsInterface.js +++ b/src/popup/detailsInterface.js @@ -3,6 +3,7 @@ module.exports = DetailsInterface; const m = require("mithril"); const Moment = require("moment"); const helpers = require("../helpers"); +const helpersUI = require("../helpers.ui"); const layout = require("./layoutInterface"); const Login = require("./models/Login"); const Settings = require("./models/Settings"); @@ -97,7 +98,7 @@ function DetailsInterface(settingsModel) { const storeColor = Login.prototype.getStore(loginObj, "color"); const secret = (loginObj.hasOwnProperty("fields") ? loginObj.fields.secret : null) || ""; - const passChars = helpers.highlight(secret); + const passChars = helpersUI.highlight(secret); var nodes = []; nodes.push( diff --git a/src/popup/interface.js b/src/popup/interface.js index 4054e45a..9c7f819e 100644 --- a/src/popup/interface.js +++ b/src/popup/interface.js @@ -3,9 +3,11 @@ module.exports = Interface; const m = require("mithril"); const Moment = require("moment"); const SearchInterface = require("./searchinterface"); -const layout = require("./layoutInterface"); +const BrowserpassURL = require("@browserpass/url"); +const dialog = require("./modalDialog"); const helpers = require("../helpers"); -const Settings = require("./models/Settings"); +const layout = require("./layoutInterface"); +let overrideDefaultSearchOnce = true; /** * Popup main interface @@ -228,7 +230,15 @@ function renderMainView(ctl, params) { * @return void */ function search(searchQuery) { - this.results = helpers.filterSortLogins(this.logins, searchQuery, this.currentDomainOnly); + const authUrl = overrideDefaultSearchOnce && helpers.parseAuthUrl(this.settings.tab.url); + + if (overrideDefaultSearchOnce && this.settings.authRequested && authUrl) { + const authUrlInfo = new BrowserpassURL(authUrl); + this.results = helpers.filterSortLogins(this.logins, authUrlInfo.domain, true); + } else { + this.results = helpers.filterSortLogins(this.logins, searchQuery, this.currentDomainOnly); + } + overrideDefaultSearchOnce = false; } /** @@ -309,7 +319,15 @@ function keyHandler(e) { break; case "KeyG": if (e.ctrlKey) { - this.doAction(e.shiftKey ? "launchInNewTab" : "launch"); + const event = e; + const target = this; + dialog.open( + helpers.LAUNCH_URL_DEPRECATION_MESSAGE, + function () { + target.doAction(event.shiftKey ? "launchInNewTab" : "launch"); + }, + false + ); } break; case "KeyO": diff --git a/src/popup/modalDialog.js b/src/popup/modalDialog.js index 9d394b1b..58f25d94 100644 --- a/src/popup/modalDialog.js +++ b/src/popup/modalDialog.js @@ -1,4 +1,5 @@ const m = require("mithril"); +const redraw = require("../helpers.redraw"); const modalId = "browserpass-modal"; const CANCEL = "Cancel"; @@ -41,15 +42,17 @@ let Modal = { return m("dialog", { id: modalId }, [ m(".modal-content", {}, m.trust(modalContent)), m(".modal-actions", {}, [ - m( - "button.cancel", - { - onclick: () => { - buttonClick(false); - }, - }, - cancelButtonText - ), + cancelButtonText + ? m( + "button.cancel", + { + onclick: () => { + buttonClick(false); + }, + }, + cancelButtonText + ) + : null, m( "button.confirm", { @@ -67,23 +70,48 @@ let Modal = { * * @since 3.8.0 * - * @param {string} message message or html to render in main body of dialog + * @param {string} request object, with type, or string message (html) to render in main body of dialog * @param {function} callback function which accepts a single boolean argument * @param {string} cancelText text to display on the negative response button * @param {string} confirmText text to display on the positive response button */ open: ( - message = "", + request = "", callback = (resp = false) => {}, cancelText = CANCEL, confirmText = CONFIRM ) => { - if (!message.length || typeof callback !== "function") { + if (typeof callback !== "function") { return null; } + let message = ""; + let type = "info"; + switch (typeof request) { + case "string": + if (!request.length) { + return null; + } + message = request; + break; + case "object": + if (typeof request?.message !== "string") { + return null; + } + message = request.message; + + if (["info", "warning", "error"].includes(request?.type)) { + type = request.type; + } + break; + default: + return null; + } + if (typeof cancelText == "string" && cancelText.length) { cancelButtonText = cancelText; + } else if (cancelText === false) { + cancelButtonText = undefined; } else { cancelButtonText = CANCEL; } @@ -95,10 +123,14 @@ let Modal = { } modalElement = document.getElementById(modalId); + modalElement.classList.remove(...modalElement.classList); + modalElement.classList.add([type]); callBackFn = callback; modalContent = message; modalElement.showModal(); m.redraw(); + + redraw.increaseModalHeight(modalElement); }, }; diff --git a/src/popup/models/Login.js b/src/popup/models/Login.js index 8d2e53bf..7acd2ca9 100644 --- a/src/popup/models/Login.js +++ b/src/popup/models/Login.js @@ -3,6 +3,7 @@ require("chrome-extension-async"); const sha1 = require("sha1"); const helpers = require("../../helpers"); +const helpersUI = require("../../helpers.ui"); const Settings = require("./Settings"); // Search for one of the secret prefixes @@ -54,7 +55,7 @@ function Login(settings, login = {}) { this.settings = settings; // This ensures doAction works in detailInterface, // and any other view in which it is necessary. - this.doAction = helpers.withLogin.bind({ + this.doAction = helpersUI.withLogin.bind({ settings: settings, login: login }); } diff --git a/src/popup/notifications.js b/src/popup/notifications.js index 92b21c03..b4e4dc3a 100644 --- a/src/popup/notifications.js +++ b/src/popup/notifications.js @@ -5,7 +5,9 @@ */ const m = require("mithril"); +const redraw = require("../helpers.redraw"); const uuidPrefix = RegExp(/^([a-z0-9]){8}-/); +const NOTIFY_CLASS = "m-notifications"; /** * Generate a globally unique id @@ -85,11 +87,16 @@ function addError(text, timeout = 5000) { } let Notifications = { + onupdate: function () { + setTimeout(() => { + redraw.increaseModalHeight(document.getElementsByClassName(NOTIFY_CLASS)[0], 25); + }, 25); + }, view(vnode) { let ui = vnode.state; return state.list ? m( - ".m-notifications", + `.${NOTIFY_CLASS}`, state.list.map((msg) => { return m("div", { key: msg.id }, m(Notification, msg)); //wrap in div with key for proper dom updates }) diff --git a/src/popup/popup.js b/src/popup/popup.js index ff654ce0..41f128e3 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -7,7 +7,7 @@ require("chrome-extension-async"); const Login = require("./models/Login"); const Settings = require("./models/Settings"); // utils, libs -const helpers = require("../helpers"); +const helpers = require("../helpers.ui"); const m = require("mithril"); // components const AddEditInterface = require("./addEditInterface"); @@ -43,6 +43,15 @@ async function run() { root.classList.remove("colors-dark"); root.classList.add(`colors-${settings.theme}`); + /** + * Only set width: min-content for the attached popup, + * and allow content to fill detached window + */ + if (!Object.prototype.hasOwnProperty.call(settings, "authRequested")) { + root.classList.add("attached"); + document.getElementsByTagName("body")[0].classList.add("attached"); + } + // set theme const theme = settings.theme === "auto" diff --git a/src/popup/popup.less b/src/popup/popup.less index 0db03f65..92df5850 100644 --- a/src/popup/popup.less +++ b/src/popup/popup.less @@ -36,6 +36,10 @@ body { min-width: 260px; overflow-x: hidden; white-space: nowrap; +} + +html.attached, +body.attached { width: min-content; } @@ -612,6 +616,10 @@ dialog#browserpass-modal { .modal-content { margin-bottom: 15px; white-space: pre-wrap; + + p { + margin: 0; + } } .modal-actions { @@ -651,3 +659,11 @@ dialog#browserpass-modal { opacity: 0; } } + +.generate-heights(@start, @end, @i: 0, @step: 25) when ((@start + @i * @step) =< @end) { + .mh-@{i} { + min-height: (1px * @start) + (1px * @i * @step); + } + .generate-heights(@start, @end, (@i + 1)); +} +.generate-heights(300, 1000); diff --git a/src/popup/searchinterface.js b/src/popup/searchinterface.js index 9374fd79..69650245 100644 --- a/src/popup/searchinterface.js +++ b/src/popup/searchinterface.js @@ -1,6 +1,8 @@ module.exports = SearchInterface; const BrowserpassURL = require("@browserpass/url"); +const dialog = require("./modalDialog"); +const helpers = require("../helpers"); const m = require("mithril"); /** @@ -30,7 +32,17 @@ function SearchInterface(popup) { */ function view(ctl, params) { var self = this; - var host = new BrowserpassURL(this.popup.settings.origin).host; + + let url = ""; + const authUrl = helpers.parseAuthUrl(window?.location?.href ?? null); + if (this.popup.settings.authRequested && authUrl) { + url = new BrowserpassURL(authUrl); + } else { + url = new BrowserpassURL(this.popup.settings.origin); + } + + var host = url.host; + return m( "form.part.search", { @@ -121,8 +133,16 @@ function view(ctl, params) { case "KeyG": if (e.ctrlKey && e.target.selectionStart == e.target.selectionEnd) { e.preventDefault(); - self.popup.results[0].doAction( - e.shiftKey ? "launchInNewTab" : "launch" + const event = e; + const target = self.popup.results[0]; + dialog.open( + helpers.LAUNCH_URL_DEPRECATION_MESSAGE, + function () { + target.doAction( + event.shiftKey ? "launchInNewTab" : "launch" + ); + }, + false ); } break;