/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ts=2 et sw=2 tw=80: */ /* 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/. */ /** * Contains elements of the Content Analysis UI, which are integrated into * various browser behaviors (uploading, downloading, printing, etc) that * require content analysis to be done. * The content analysis itself is done by the clients of this script, who * use nsIContentAnalysis to talk to the external CA system. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; XPCOMUtils.defineLazyServiceGetter( lazy, "gContentAnalysis", "@mozilla.org/contentanalysis;1", Ci.nsIContentAnalysis ); ChromeUtils.defineESModuleGetters(lazy, { clearTimeout: "resource://gre/modules/Timer.sys.mjs", PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "silentNotifications", "browser.contentanalysis.silent_notifications", false ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "agentName", "browser.contentanalysis.agent_name", "A DLP agent" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "showBlockedResult", "browser.contentanalysis.show_blocked_result", true ); class RequestInfos { /** * A map from request token to an entry * * @type {Map} */ #map; constructor() { this.#map = new Map(); } /** * Gets the request with the specified request token and removes * it from the map. * * @param {string} aRequestToken the request token to search for * @returns {object | undefined} the existing data, or `undefined` if there is none */ getAndRemoveEntry(aRequestToken) { const entry = this.#map.get(aRequestToken); this.#map.delete(aRequestToken); return entry; } /** * Adds or replaces the associated entry. * * @param {object} aValue the data to associated with the browsing context */ addOrReplaceEntry(aValue) { if (!aValue.request) { console.error( "MapByTopBrowsingContext.setEntry() called with a value without a request!" ); } this.#map.set(aValue.request.requestToken, aValue); } /** * Returns all requests that have the passed-in userActionId and * removes them from the map. * * @param {string} aUserActionId the user action id to search for * @returns {Array} any data that matches the user action id */ getAndRemoveEntriesByUserActionId(aUserActionId) { const entries = this.#map .values() .filter(entry => entry.request.userActionId == aUserActionId) .toArray(); entries.forEach(entry => this.#map.delete(entry.request.requestToken)); return entries; } /** * Gets all requests. * * @returns {Array} all the requests */ getAllRequests() { return this.#map .values() .map(entry => entry.request) .toArray(); } } export const ContentAnalysis = { _SHOW_NOTIFICATIONS: true, _SHOW_DIALOGS: false, _SLOW_DLP_NOTIFICATION_BLOCKING_TIMEOUT_MS: 250, _SLOW_DLP_NOTIFICATION_NONBLOCKING_TIMEOUT_MS: 3 * 1000, _RESULT_NOTIFICATION_TIMEOUT_MS: 5 * 60 * 1000, // 5 min _RESULT_NOTIFICATION_FAST_TIMEOUT_MS: 60 * 1000, // 1 min PROMPTID_PREFIX: "ContentAnalysisDialog-", isInitialized: false, requestInfos: new RequestInfos(), /** * @type {Map} */ requestTokenToRequestInfo: new Map(), /** * Registers for various messages/events that will indicate the * need for communicating something to the user. */ initialize(window) { if (!lazy.gContentAnalysis.isActive) { return; } let doc = window.document; if (!this.isInitialized) { this.isInitialized = true; this.initializeDownloadCA(); ChromeUtils.defineLazyGetter(this, "l10n", function () { return new Localization( ["branding/brand.ftl", "toolkit/contentanalysis/contentanalysis.ftl"], true ); }); } // Do this even if initialized so the icon shows up on new windows, not just the // first one. for (let indicator of doc.getElementsByClassName( "content-analysis-indicator" )) { doc.l10n.setAttributes(indicator, "content-analysis-indicator-tooltip", { agentName: lazy.agentName, }); } doc.documentElement.setAttribute("contentanalysisactive", "true"); }, async uninitialize() { if (this.isInitialized) { this.isInitialized = false; this.requestTokenToRequestInfo.clear(); } }, /** * Register UI for file download CA events. */ async initializeDownloadCA() { Services.obs.addObserver(this, "dlp-request-made"); Services.obs.addObserver(this, "dlp-response"); Services.obs.addObserver(this, "quit-application"); Services.obs.addObserver(this, "quit-application-requested"); }, // nsIObserver async observe(aSubj, aTopic, _aData) { switch (aTopic) { case "quit-application-requested": { let quitCancelled = false; let pendingRequests = this.requestInfos.getAllRequests(); if (pendingRequests.length) { let messageBody = this.l10n.formatValueSync( "contentanalysis-inprogress-quit-message" ); messageBody = messageBody + "\n\n"; for (const pendingRequest of pendingRequests) { let name = this._getResourceNameFromNameOrOperationType( this._getResourceNameOrOperationTypeFromRequest( pendingRequest, true ) ); messageBody = messageBody + name + "\n"; } let buttonSelected = Services.prompt.confirmEx( null, this.l10n.formatValueSync("contentanalysis-inprogress-quit-title"), messageBody, 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_0_DEFAULT, this.l10n.formatValueSync( "contentanalysis-inprogress-quit-yesbutton" ), null, null, null, { value: 0 } ); if (buttonSelected === 1) { aSubj.data = true; quitCancelled = true; } } if (!quitCancelled) { // Ideally we would wait until "quit-application" to cancel outstanding // DLP requests, but the "DLP busy" or "DLP blocked" dialog can block the // main thread, thus preventing the "quit-application" from being sent, // which causes a shutdownhang. (bug 1899703) lazy.gContentAnalysis.cancelAllRequests(true); } break; } case "quit-application": { this.uninitialize(); break; } case "dlp-request-made": { const request = aSubj.QueryInterface(Ci.nsIContentAnalysisRequest); if (!request) { console.error( "Showing in-browser Content Analysis notification but no request was passed" ); return; } const analysisType = request.analysisType; // For operations that block browser interaction, show the "slow content analysis" // dialog faster let slowTimeoutMs = this._shouldShowBlockingNotification(analysisType) ? this._SLOW_DLP_NOTIFICATION_BLOCKING_TIMEOUT_MS : this._SLOW_DLP_NOTIFICATION_NONBLOCKING_TIMEOUT_MS; let browsingContext = request.windowGlobalParent?.browsingContext; if (!browsingContext) { throw new Error( "Got dlp-request-made message but couldn't find a browsingContext!" ); } // Start timer that, when it expires, // presents a "slow CA check" message. let resourceNameOrOperationType = this._getResourceNameOrOperationTypeFromRequest(request, false); this.requestTokenToRequestInfo.set(request.requestToken, { browsingContext, resourceNameOrOperationType, }); this.requestInfos.addOrReplaceEntry({ timer: lazy.setTimeout(() => { this.requestInfos.addOrReplaceEntry({ notification: this._showSlowCAMessage( analysisType, request, resourceNameOrOperationType, browsingContext ), request, }); }, slowTimeoutMs), request, }); } break; case "dlp-response": { const response = aSubj.QueryInterface(Ci.nsIContentAnalysisResponse); // Cancels timer or slow message UI, // if present, and possibly presents the CA verdict. if (!response) { throw new Error( "Got dlp-response message but no response object was passed" ); } let windowAndResourceNameOrOperationType = this.requestTokenToRequestInfo.get(response.requestToken); if (!windowAndResourceNameOrOperationType) { // We may get multiple responses, for example, if we are blocked or // canceled after receiving our verdict because we were part of a // multipart transaction. Just ignore that. console.warn( `Got dlp-response message with unknown token ${response.requestToken} | action: ${response.action}` ); return; } this.requestTokenToRequestInfo.delete(response.requestToken); let dlpBusyView = this.requestInfos.getAndRemoveEntry( response.requestToken ); this._disconnectFromView(dlpBusyView); const responseResult = response?.action ?? Ci.nsIContentAnalysisResponse.eUnspecified; // Don't show dialog if this is a cached response if (!response?.isCachedResponse) { await this._showCAResult( windowAndResourceNameOrOperationType.resourceNameOrOperationType, windowAndResourceNameOrOperationType.browsingContext, response.requestToken, response.userActionId, responseResult, response.isAgentResponse, response.cancelError ); } break; } } }, async showPanel(element, panelUI) { element.ownerDocument.l10n.setAttributes( lazy.PanelMultiView.getViewNode( element.ownerDocument, "content-analysis-panel-description" ), "content-analysis-panel-text-styled", { agentName: lazy.agentName } ); panelUI.showSubView("content-analysis-panel", element); }, _disconnectFromView(caView) { if (!caView) { return; } if (caView.timer) { lazy.clearTimeout(caView.timer); } else if (caView.notification) { if (caView.notification.close) { // native notification caView.notification.close(); } else if (caView.notification.dialogBrowsingContext) { // in-browser notification let browser = caView.notification.dialogBrowsingContext.top.embedderElement; // browser will be null if the tab was closed let win = browser?.ownerGlobal; if (win) { let dialogBox = win.gBrowser.getTabDialogBox(browser); // Just close the dialog associated with this CA request. dialogBox.getTabDialogManager().abortDialogs(dialog => { return ( dialog.promptID == this.PROMPTID_PREFIX + caView.request.requestToken ); }); } } else { console.error( "Unexpected content analysis notification - can't close it!" ); } } }, _showMessage(aMessage, aBrowsingContext, aTimeout = 0) { if (this._SHOW_DIALOGS) { Services.prompt.asyncAlert( aBrowsingContext, Ci.nsIPrompt.MODAL_TYPE_WINDOW, this.l10n.formatValueSync("contentanalysis-alert-title"), aMessage ); } if (this._SHOW_NOTIFICATIONS) { let topWindow = aBrowsingContext.topChromeWindow ?? aBrowsingContext.embedderWindowGlobal.browsingContext.topChromeWindow; const notification = new topWindow.Notification( this.l10n.formatValueSync("contentanalysis-notification-title"), { body: aMessage, silent: lazy.silentNotifications } ); if (aTimeout != 0) { lazy.setTimeout(() => { notification.close(); }, aTimeout); } return notification; } return null; }, _shouldShowBlockingNotification(aAnalysisType) { return !( aAnalysisType == Ci.nsIContentAnalysisRequest.eFileDownloaded || aAnalysisType == Ci.nsIContentAnalysisRequest.ePrint ); }, // This function also transforms the nameOrOperationType so we won't have to // look it up again. _getResourceNameFromNameOrOperationType(nameOrOperationType) { if (!nameOrOperationType.name) { let l10nId = undefined; switch (nameOrOperationType.operationType) { case Ci.nsIContentAnalysisRequest.eClipboard: l10nId = "contentanalysis-operationtype-clipboard"; break; case Ci.nsIContentAnalysisRequest.eDroppedText: l10nId = "contentanalysis-operationtype-dropped-text"; break; case Ci.nsIContentAnalysisRequest.eOperationPrint: l10nId = "contentanalysis-operationtype-print"; break; } if (!l10nId) { console.error( "Unknown operationTypeForDisplay: " + nameOrOperationType.operationType ); return ""; } nameOrOperationType.name = this.l10n.formatValueSync(l10nId); } return nameOrOperationType.name; }, /** * Gets a name or operation type from a request * * @param {object} aRequest The nsIContentAnalysisRequest * @param {boolean} aStandalone Whether the message is going to be used on its own * line. This is used to add more context to the message * if a file is being uploaded rather than just the name * of the file. * @returns {object} An object with either a name property that can be used as-is, or * an operationType property. */ _getResourceNameOrOperationTypeFromRequest(aRequest, aStandalone) { if ( aRequest.operationTypeForDisplay == Ci.nsIContentAnalysisRequest.eCustomDisplayString ) { if (aStandalone) { return { name: this.l10n.formatValueSync( "contentanalysis-customdisplaystring-description", { filename: aRequest.operationDisplayString } ), }; } return { name: aRequest.operationDisplayString }; } return { operationType: aRequest.operationTypeForDisplay }; }, /** * Show a message to the user to indicate that a CA request is taking * a long time. */ _showSlowCAMessage( aOperation, aRequest, aResourceNameOrOperationType, aBrowsingContext ) { if (!this._shouldShowBlockingNotification(aOperation)) { return this._showMessage( this._getSlowDialogMessage(aResourceNameOrOperationType), aBrowsingContext ); } if (!aRequest) { throw new Error( "Showing in-browser Content Analysis notification but no request was passed" ); } return this._showSlowCABlockingMessage( aBrowsingContext, aRequest.requestToken, aResourceNameOrOperationType ); }, _getSlowDialogMessage(aResourceNameOrOperationType) { if (aResourceNameOrOperationType.name) { return this.l10n.formatValueSync( "contentanalysis-slow-agent-dialog-body-file", { agent: lazy.agentName, filename: aResourceNameOrOperationType.name } ); } let l10nId = undefined; switch (aResourceNameOrOperationType.operationType) { case Ci.nsIContentAnalysisRequest.eClipboard: l10nId = "contentanalysis-slow-agent-dialog-body-clipboard"; break; case Ci.nsIContentAnalysisRequest.eDroppedText: l10nId = "contentanalysis-slow-agent-dialog-body-dropped-text"; break; case Ci.nsIContentAnalysisRequest.eOperationPrint: l10nId = "contentanalysis-slow-agent-dialog-body-print"; break; } if (!l10nId) { console.error( "Unknown operationTypeForDisplay: ", aResourceNameOrOperationType ); return ""; } return this.l10n.formatValueSync(l10nId, { agent: lazy.agentName }); }, _getErrorDialogMessage(aResourceNameOrOperationType) { if (aResourceNameOrOperationType.name) { return this.l10n.formatValueSync( "contentanalysis-error-message-upload-file", { filename: aResourceNameOrOperationType.name } ); } let l10nId = undefined; switch (aResourceNameOrOperationType.operationType) { case Ci.nsIContentAnalysisRequest.eClipboard: l10nId = "contentanalysis-error-message-clipboard"; break; case Ci.nsIContentAnalysisRequest.eDroppedText: l10nId = "contentanalysis-error-message-dropped-text"; break; case Ci.nsIContentAnalysisRequest.eOperationPrint: l10nId = "contentanalysis-error-message-print"; break; } if (!l10nId) { console.error( "Unknown operationTypeForDisplay: ", aResourceNameOrOperationType ); return ""; } return this.l10n.formatValueSync(l10nId); }, _showSlowCABlockingMessage( aBrowsingContext, aRequestToken, aResourceNameOrOperationType ) { let bodyMessage = this._getSlowDialogMessage(aResourceNameOrOperationType); // Note that TabDialogManager maintains a list of displaying dialogs, and so // we can pop up multiple of these and the first one will keep displaying until // it is closed, at which point the next one will display, etc. let promise = Services.prompt.asyncConfirmEx( aBrowsingContext, Ci.nsIPromptService.MODAL_TYPE_TAB, this.l10n.formatValueSync("contentanalysis-slow-agent-dialog-header"), bodyMessage, Ci.nsIPromptService.BUTTON_POS_0 * Ci.nsIPromptService.BUTTON_TITLE_CANCEL + Ci.nsIPromptService.BUTTON_POS_1_DEFAULT + Ci.nsIPromptService.SHOW_SPINNER, null, null, null, null, false, { promptID: this.PROMPTID_PREFIX + aRequestToken } ); promise .catch(() => { // need a catch clause to avoid an unhandled JS exception // when we programmatically close the dialog. // Since this only happens when we are programmatically closing // the dialog, no need to log the exception. }) .finally(() => { // This is also be called if the tab/window is closed while a request is in progress, // in which case we need to cancel the request. if (this.requestTokenToRequestInfo.delete(aRequestToken)) { lazy.gContentAnalysis.cancelRequestsByRequestToken(aRequestToken); let dlpBusyView = this.requestInfos.getAndRemoveEntry(aRequestToken); this._disconnectFromView(dlpBusyView); } }); return { requestToken: aRequestToken, dialogBrowsingContext: aBrowsingContext, }; }, /** * Show a message to the user to indicate the result of a CA request. * * @returns {object} a notification object (if shown) */ async _showCAResult( aResourceNameOrOperationType, aBrowsingContext, aRequestToken, aUserActionId, aCAResult, aIsAgentResponse, aRequestCancelError ) { let message = null; let timeoutMs = 0; switch (aCAResult) { case Ci.nsIContentAnalysisResponse.eAllow: // We don't need to show anything return null; case Ci.nsIContentAnalysisResponse.eReportOnly: message = await this.l10n.formatValue( "contentanalysis-genericresponse-message", { content: this._getResourceNameFromNameOrOperationType( aResourceNameOrOperationType ), response: "REPORT_ONLY", } ); timeoutMs = this._RESULT_NOTIFICATION_FAST_TIMEOUT_MS; break; case Ci.nsIContentAnalysisResponse.eWarn: { let allow = false; try { const result = await Services.prompt.asyncConfirmEx( aBrowsingContext, Ci.nsIPromptService.MODAL_TYPE_TAB, await this.l10n.formatValue("contentanalysis-warndialogtitle"), await this._warnDialogText(aResourceNameOrOperationType), Ci.nsIPromptService.BUTTON_POS_0 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING + Ci.nsIPromptService.BUTTON_POS_1 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING + Ci.nsIPromptService.BUTTON_POS_2_DEFAULT, await this.l10n.formatValue( "contentanalysis-warndialog-response-allow" ), await this.l10n.formatValue( "contentanalysis-warndialog-response-deny" ), null, null, {} ); allow = result.get("buttonNumClicked") === 0; } catch { // This can happen if the dialog is closed programmatically, for // example if the tab is moved to a new window. // In this case just pretend the user clicked deny, as this // emulates the behavior of cancelling when // the request is still active. allow = false; } lazy.gContentAnalysis.respondToWarnDialog(aRequestToken, allow); return null; } case Ci.nsIContentAnalysisResponse.eBlock: { if (aIsAgentResponse && !lazy.showBlockedResult) { // Don't show anything return null; } let titleId = undefined; let body = undefined; if (aResourceNameOrOperationType.name) { titleId = "contentanalysis-block-dialog-title-upload-file"; body = this.l10n.formatValueSync( "contentanalysis-block-dialog-body-upload-file", { filename: aResourceNameOrOperationType.name } ); } else { let bodyId = undefined; let bodyHasContent = false; switch (aResourceNameOrOperationType.operationType) { case Ci.nsIContentAnalysisRequest.eClipboard: { // Unlike the cases below, this can be shown when the DLP // agent is not available. We use a different message for that. const caInfo = await lazy.gContentAnalysis.getDiagnosticInfo(); titleId = "contentanalysis-block-dialog-title-clipboard"; bodyId = caInfo.connectedToAgent ? "contentanalysis-block-dialog-body-clipboard" : "contentanalysis-no-agent-connected-message-content"; bodyHasContent = true; break; } case Ci.nsIContentAnalysisRequest.eDroppedText: titleId = "contentanalysis-block-dialog-title-dropped-text"; bodyId = "contentanalysis-block-dialog-body-dropped-text"; break; case Ci.nsIContentAnalysisRequest.eOperationPrint: titleId = "contentanalysis-block-dialog-title-print"; bodyId = "contentanalysis-block-dialog-body-print"; break; } if (!titleId || !bodyId) { console.error( "Unknown operationTypeForDisplay: ", aResourceNameOrOperationType ); return null; } if (bodyHasContent) { body = this.l10n.formatValueSync(bodyId, { agent: lazy.agentName, content: "", }); } else { body = this.l10n.formatValueSync(bodyId); } } let alertBrowsingContext = aBrowsingContext; if (aBrowsingContext.embedderElement?.getAttribute("printpreview")) { // If we're in a print preview dialog, things are tricky. // The window itself is about to close (because of the thrown NS_ERROR_CONTENT_BLOCKED), // so using an async call would just immediately make the dialog disappear. (bug 1899714) // Using a blocking version can cause a hang if the window is resizing while // we show the dialog. (bug 1900798) // So instead, try to find the browser that this print preview dialog is on top of // and show the dialog there. let printPreviewBrowser = aBrowsingContext.embedderElement; let win = printPreviewBrowser.ownerGlobal; for (let browser of win.gBrowser.browsers) { if ( win.PrintUtils.getPreviewBrowser(browser)?.browserId === printPreviewBrowser.browserId ) { alertBrowsingContext = browser.browsingContext; break; } } } await Services.prompt.asyncAlert( alertBrowsingContext, Ci.nsIPromptService.MODAL_TYPE_TAB, this.l10n.formatValueSync(titleId), body ); return null; } case Ci.nsIContentAnalysisResponse.eUnspecified: message = await this.l10n.formatValue( "contentanalysis-unspecified-error-message-content", { agent: lazy.agentName, content: this._getErrorDialogMessage(aResourceNameOrOperationType), } ); timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS; break; case Ci.nsIContentAnalysisResponse.eCanceled: { let messageId; switch (aRequestCancelError) { case Ci.nsIContentAnalysisResponse.eUserInitiated: console.error( "Got unexpected cancel response with eUserInitiated" ); return null; case Ci.nsIContentAnalysisResponse.eOtherRequestInGroupCancelled: return null; case Ci.nsIContentAnalysisResponse.eNoAgent: messageId = "contentanalysis-no-agent-connected-message-content"; break; case Ci.nsIContentAnalysisResponse.eInvalidAgentSignature: messageId = "contentanalysis-invalid-agent-signature-message-content"; break; case Ci.nsIContentAnalysisResponse.eErrorOther: messageId = "contentanalysis-unspecified-error-message-content"; break; case Ci.nsIContentAnalysisResponse.eShutdown: // we're shutting down, no need to show a dialog return null; case Ci.nsIContentAnalysisResponse.eTimeout: // We only show this if the default action was to block. messageId = "contentanalysis-timeout-block-error-message-content"; break; default: console.error( "Unexpected CA cancelError value: " + aRequestCancelError ); messageId = "contentanalysis-unspecified-error-message-content"; break; } // We got an error with this request, so close any dialogs for any other request // with the same user action id and also remove their data so we don't show // any dialogs they might later try to show. const otherEntries = this.requestInfos.getAndRemoveEntriesByUserActionId(aUserActionId); otherEntries.forEach(entry => { this.requestTokenToRequestInfo.delete(entry.request.requestToken); this._disconnectFromView(entry); }); message = await this.l10n.formatValue(messageId, { agent: lazy.agentName, content: this._getErrorDialogMessage(aResourceNameOrOperationType), contentName: this._getResourceNameFromNameOrOperationType( aResourceNameOrOperationType ), }); timeoutMs = this._RESULT_NOTIFICATION_TIMEOUT_MS; } break; default: throw new Error("Unexpected CA result value: " + aCAResult); } if (!message) { console.error( "_showCAResult did not get a message populated for result value " + aCAResult ); return null; } return this._showMessage(message, aBrowsingContext, timeoutMs); }, /** * Returns the correct text for warn dialog contents. */ async _warnDialogText(aResourceNameOrOperationType) { const caInfo = await lazy.gContentAnalysis.getDiagnosticInfo(); if (caInfo.connectedToAgent) { return await this.l10n.formatValue("contentanalysis-warndialogtext", { content: this._getResourceNameFromNameOrOperationType( aResourceNameOrOperationType ), }); } return await this.l10n.formatValue( "contentanalysis-no-agent-connected-message-content", { agent: lazy.agentName, content: "" } ); }, };