/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { MailServices } from "resource:///modules/MailServices.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { isLegalIPAddress: "resource:///modules/hostnameUtils.sys.mjs", isLegalLocalIPAddress: "resource:///modules/hostnameUtils.sys.mjs", openLinkExternally: "resource:///modules/LinkHelper.sys.mjs", }); export const PhishingDetector = new (class PhishingDetector { mEnabled = true; mDisallowFormActions = true; constructor() { XPCOMUtils.defineLazyPreferenceGetter( this, "mEnabled", "mail.phishing.detection.enabled", true ); XPCOMUtils.defineLazyPreferenceGetter( this, "mDisallowFormActions", "mail.phishing.detection.disallow_form_actions", true ); } /** * Analyze the currently loaded message in the message pane, looking for signs * of a phishing attempt. Also checks for forms with action URLs, which are * disallowed. * Assumes the message has finished loading in the message pane (i.e. * DOMContentLoaded has fired). * * @param {nsIURI} url - URL for the message being analyzed. * @param {Element} browser - The browser element where the message is loaded. * @returns {boolean} True if a warning should be displayed about the content * of this message. */ async analyzeMessage(url, browser) { if (!url || !this.mEnabled) { return false; } let folder; try { const server = MailServices.accounts.findServerByURI(url); folder = server.getMsgFolderFromURI(null, url.spec); } catch (ex) { // findServerByURI can throw NS_ERROR_UNEXPECTED, especially if we are // opening an .eml file. if (ex.result != Cr.NS_ERROR_UNEXPECTED) { throw ex; } } if (folder) { // Ignore NNTP and RSS messages. if (folder.server.type == "nntp" || folder.server.type == "rss") { return false; } // Also ignore messages in Sent/Drafts/Templates/Outbox. const outgoingFlags = Ci.nsMsgFolderFlags.SentMail | Ci.nsMsgFolderFlags.Drafts | Ci.nsMsgFolderFlags.Templates | Ci.nsMsgFolderFlags.Queue; if (folder.isSpecialFolder(outgoingFlags, true)) { return false; } } // The message pane may navigate away, or go away entirely, before we get // here. If it has, there is no MailMessage actor to ask - getActor throws // for a window global which has already been swapped for a web page - and // there's nothing left to warn about anyway. let actor; try { actor = browser.browsingContext?.currentWindowGlobal?.getActor("MailMessage"); } catch (ex) { return false; } if (!actor) { return false; } try { return await actor.sendQuery("MailMessage:AnalyzeMessageBody"); } catch (ex) { // Same again, but the pane went away while the query was in flight. if (ex.name != "AbortError") { throw ex; } return false; } } /** * Analyzes the body of the currently loaded message. This part runs in the * same process as the message pane browser. * * @param {Document} document - The message body. * @returns {boolean} */ analyzeMessageBody(document) { // If the message contains forms with action attributes, warn the user. const formNodes = document.querySelectorAll("form[action]"); return this.mDisallowFormActions && formNodes.length > 0; } /** * Analyze the url contained for phishing attacks. Determine if the link node * contains a user visible url with a host name that differs from the actual * href the user would get taken to. * E.g. http://mozilla.org * * @param {string} aUrl - The url to be analyzed. * @param {string} aLinkText - User visible link text associated with aUrl. * @returns {boolean} true if link node contains phishing URL. */ linkTextMismatch(aUrl, aLinkText) { if (!aUrl || !aLinkText || !URL.canParse(aUrl)) { return false; } const hrefURL = new URL(aUrl); // Only check for phishing urls if the url is an http or https link. // this prevents us from flagging imap and other internally handled urls if (hrefURL.protocol != "http:" && hrefURL.protocol != "https:") { return false; } // The link is not suspicious if the visible text is the same as the URL, // even if the URL is an IP address. URLs are commonly surrounded by // < > or "" (RFC2396E) - so strip those from the link text before comparing. aLinkText = aLinkText.replace(/^<(.+)>$|^"(.+)"$/, "$1$2"); // gatherTextUnder puts a space between each piece of text it gathers, // so strip the spaces out (see bug 326082 for details). aLinkText = aLinkText.replace(/ /g, ""); if (!URL.canParse(aLinkText)) { return false; } const textURL = new URL(aLinkText); if (textURL.protocol != "http:" && textURL.protocol != "https:") { return false; } if (hrefURL.hostname == textURL.hostname) { return false; } const hrefURI = Services.io.newURI(aUrl); const linkTextURI = Services.io.newURI(aLinkText); // Compare the base domain of the href and the link text. try { return ( Services.eTLD.getBaseDomain(hrefURI) != Services.eTLD.getBaseDomain(linkTextURI) ); } catch (e) { // If we throw above, one of the URIs probably has no TLD (e.g. // http://localhost), so just check the entire host. return hrefURI.host != linkTextURI.host; } } /** * Opens the default browser to a page where the user can submit the given url * as a phish. * * @param {string} aPhishingURL - The url we want to report back as a phishing attack. */ reportPhishingURL(aPhishingURL) { let reportUrl = Services.urlFormatter.formatURLPref( "browser.safebrowsing.reportPhishURL" ); reportUrl += "&url=" + encodeURIComponent(aPhishingURL); lazy.openLinkExternally(reportUrl, { addToHistory: false }); } /** * Checks for link mismatch and if warranted, prompts the * user with a warning before allowing the link click to be processed. * The warning prompt includes the unobscured host name of the http(s) url the * user clicked on. * * @param {DOMWindow} win - The window the message is being displayed within. * @param {string} aUrl - The url of the message * @param {string} aLinkText - User visible link text associated with the link * @returns {0|1|2} * 0 if the URL implied by aLinkText should be used instead. * 1 if the request should be blocked. * 2 if aUrl should be allowed to load. */ warnOnSuspiciousLinkClick(win, aUrl, aLinkText) { if (!aUrl || !URL.canParse(aUrl)) { return 1; // block } const bundle = Services.strings.createBundle( "chrome://messenger/locale/messenger.properties" ); const hrefURL = new URL(aUrl); if (hrefURL.protocol != "http:" && hrefURL.protocol != "https:") { return 2; // allow } if (!this.linkTextMismatch(aUrl, aLinkText)) { return 2; // allow } // Unobscure the hostname in case it's an encoded IP address. const unobscuredHostname = lazy.isLegalIPAddress(hrefURL.hostname, true); if (unobscuredHostname && !lazy.isLegalLocalIPAddress(unobscuredHostname)) { const brandBundle = Services.strings.createBundle( "chrome://branding/locale/brand.properties" ); const titleMsg = bundle.GetStringFromName("confirmPhishingTitle"); const brandShortName = brandBundle.GetStringFromName("brandShortName"); const dialogMsg = bundle.formatStringFromName("confirmPhishingUrl", [ brandShortName, unobscuredHostname, ]); const button = Services.prompt.confirmEx( win, titleMsg, dialogMsg, Ci.nsIPromptService.STD_YES_NO_BUTTONS + Ci.nsIPromptService.BUTTON_POS_1_DEFAULT, "", "", "", "", {} ); return button == 0 ? 2 : 1; // 2 == allow, 1 == block } // We have a mismatching hostname. Prompt the user what to do. const actualURL = hrefURL; const displayedURL = new URL(aLinkText); const titleMsg = bundle.GetStringFromName("linkMismatchTitle"); const dialogMsg = bundle.formatStringFromName( "confirmPhishingUrlAlternate", [displayedURL.hostname, actualURL.hostname] ); const warningButtons = Ci.nsIPromptService.BUTTON_POS_0 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING + Ci.nsIPromptService.BUTTON_POS_1 * Ci.nsIPromptService.BUTTON_TITLE_CANCEL + Ci.nsIPromptService.BUTTON_POS_2 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING; const button0Text = bundle.formatStringFromName("confirmPhishingGoDirect", [ displayedURL.hostname, ]); const button2Text = bundle.formatStringFromName("confirmPhishingGoAhead", [ actualURL.hostname, ]); return Services.prompt.confirmEx( win, titleMsg, dialogMsg, warningButtons, button0Text, "", button2Text, "", {} ); } })();