diff --git a/browser/components/downloads/DownloadsCommon.sys.mjs b/browser/components/downloads/DownloadsCommon.sys.mjs index ddb468e2da23..4679d650f848 100644 --- a/browser/components/downloads/DownloadsCommon.sys.mjs +++ b/browser/components/downloads/DownloadsCommon.sys.mjs @@ -143,6 +143,7 @@ export var DownloadsCommon = { DOWNLOAD_BLOCKED_PARENTAL: 6, DOWNLOAD_DIRTY: 8, DOWNLOAD_BLOCKED_POLICY: 9, + DOWNLOAD_BLOCKED_CONTENT_ANALYSIS: 10, // The following are the possible values of the "attention" property. ATTENTION_NONE: "", @@ -295,6 +296,18 @@ export var DownloadsCommon = { if (download.error.becauseBlockedByReputationCheck) { return DownloadsCommon.DOWNLOAD_DIRTY; } + if (download.error.becauseBlockedByContentAnalysis) { + // BLOCK_VERDICT_MALWARE indicates that the download was + // blocked by the content analysis service, so return + // DOWNLOAD_BLOCKED_CONTENT_ANALYSIS to indicate this. + // Otherwise, the content analysis service returned + // WARN, so the user has a chance to unblock the download, + // which corresponds with DOWNLOAD_DIRTY. + return download.error.reputationCheckVerdict === + lazy.Downloads.Error.BLOCK_VERDICT_MALWARE + ? DownloadsCommon.DOWNLOAD_BLOCKED_CONTENT_ANALYSIS + : DownloadsCommon.DOWNLOAD_DIRTY; + } return DownloadsCommon.DOWNLOAD_FAILED; } if (download.canceled) { @@ -311,7 +324,7 @@ export var DownloadsCommon = { */ async deleteDownload(download) { // Check hasBlockedData to avoid double counting if you click the X button - // in the Libarary view and then delete the download from the history. + // in the Library view and then delete the download from the history. if ( download.error?.becauseBlockedByReputationCheck && download.hasBlockedData @@ -331,6 +344,9 @@ export var DownloadsCommon = { } let list = await lazy.Downloads.getList(lazy.Downloads.ALL); await list.remove(download); + if (download.error?.becauseBlockedByContentAnalysis) { + await download.respondToContentAnalysisWarnWithBlock(); + } await download.finalize(true); }, @@ -359,6 +375,9 @@ export var DownloadsCommon = { await list.remove(download); } await download.manuallyRemoveData(); + if (download.error?.becauseBlockedByContentAnalysis) { + await download.respondToContentAnalysisWarnWithBlock(); + } if (clearHistory < 2) { lazy.DownloadHistory.updateMetaData(download).catch(console.error); } @@ -629,6 +648,8 @@ export var DownloadsCommon = { * the "Downloads.Error.BLOCK_VERDICT_" constants. If an unknown * reason is specified, "Downloads.Error.BLOCK_VERDICT_MALWARE" is * assumed. + * becauseBlockedByReputationCheck: + * Whether the the download was blocked by a reputation check. * window: * The window with which this action is associated. * dialogType: @@ -645,7 +666,12 @@ export var DownloadsCommon = { * - "confirmBlock" to delete the blocked data permanently. * - "cancel" to do nothing and cancel the operation. */ - async confirmUnblockDownload({ verdict, window, dialogType }) { + async confirmUnblockDownload({ + verdict, + becauseBlockedByReputationCheck, + window, + dialogType, + }) { let s = DownloadsCommon.strings; // All the dialogs have an action button and a cancel button, while only @@ -685,12 +711,18 @@ export var DownloadsCommon = { } let message; + let tip = s.unblockTip2; switch (verdict) { case lazy.Downloads.Error.BLOCK_VERDICT_UNCOMMON: message = s.unblockTypeUncommon2; break; case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: - message = s.unblockTypePotentiallyUnwanted2; + if (becauseBlockedByReputationCheck) { + message = s.unblockTypePotentiallyUnwanted2; + } else { + message = s.unblockTypeContentAnalysisWarn; + tip = s.unblockContentAnalysisTip; + } break; case lazy.Downloads.Error.BLOCK_VERDICT_INSECURE: message = s.unblockInsecure2; @@ -700,7 +732,7 @@ export var DownloadsCommon = { message = s.unblockTypeMalware; break; } - message += "\n\n" + s.unblockTip2; + message += "\n\n" + tip; Services.ww.registerNotification(function onOpen(subj, topic) { if (topic == "domwindowopened" && subj instanceof Ci.nsIDOMWindow) { @@ -860,7 +892,10 @@ DownloadsDataCtor.prototype = { download, DownloadsCommon.stateOfDownload(download) ); - if (download.error?.becauseBlockedByReputationCheck) { + if ( + download.error?.becauseBlockedByReputationCheck || + download.error?.becauseBlockedByContentAnalysis + ) { this._notifyDownloadEvent("error"); } }, diff --git a/browser/components/downloads/DownloadsViewUI.sys.mjs b/browser/components/downloads/DownloadsViewUI.sys.mjs index 548eedc43ab2..a71382729922 100644 --- a/browser/components/downloads/DownloadsViewUI.sys.mjs +++ b/browser/components/downloads/DownloadsViewUI.sys.mjs @@ -770,7 +770,10 @@ DownloadsViewUI.DownloadElementShell.prototype = { lazy.DownloadsCommon.strings.stateBlockedParentalControls ); this.hideButton(); - } else if (this.download.error.becauseBlockedByReputationCheck) { + } else if ( + this.download.error.becauseBlockedByReputationCheck || + this.download.error.becauseBlockedByContentAnalysis + ) { verdict = this.download.error.reputationCheckVerdict; let hover = ""; if (!this.download.hasBlockedData) { @@ -882,7 +885,8 @@ DownloadsViewUI.DownloadElementShell.prototype = { let s = lazy.DownloadsCommon.strings; if ( !this.download.error || - !this.download.error.becauseBlockedByReputationCheck + (!this.download.error.becauseBlockedByReputationCheck && + !this.download.error.becauseBlockedByContentAnalysis) ) { return [null, null]; } @@ -895,14 +899,37 @@ DownloadsViewUI.DownloadElementShell.prototype = { [s.unblockInsecure2, s.unblockTip2], ]; case lazy.Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED: + if (this.download.error.becauseBlockedByReputationCheck) { + return [ + s.blockedPotentiallyUnwanted, + [s.unblockTypePotentiallyUnwanted2, s.unblockTip2], + ]; + } + if (!this.download.error.becauseBlockedByContentAnalysis) { + // We expect one of becauseBlockedByReputationCheck or + // becauseBlockedByContentAnalysis to be true; if not, + // fall through to the error case. + break; + } return [ - s.blockedPotentiallyUnwanted, - [s.unblockTypePotentiallyUnwanted2, s.unblockTip2], + s.warnedByContentAnalysis, + [s.unblockTypeContentAnalysisWarn, s.unblockContentAnalysisWarnTip], ]; case lazy.Downloads.Error.BLOCK_VERDICT_MALWARE: - return [s.blockedMalware, [s.unblockTypeMalware, s.unblockTip2]]; - - case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM: + if (this.download.error.becauseBlockedByReputationCheck) { + return [s.blockedMalware, [s.unblockTypeMalware, s.unblockTip2]]; + } + if (!this.download.error.becauseBlockedByContentAnalysis) { + // We expect one of becauseBlockedByReputationCheck or + // becauseBlockedByContentAnalysis to be true; if not, + // fall through to the error case. + break; + } + return [ + s.blockedByContentAnalysis, + [s.unblockContentAnalysis1, s.unblockContentAnalysis2], + ]; + case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM: { let title = { id: "downloads-files-not-downloaded", args: { @@ -914,6 +941,7 @@ DownloadsViewUI.DownloadElementShell.prototype = { args: { url: DownloadsViewUI.getStrippedUrl(this.download) }, }; return [{ l10n: title }, [{ l10n: details }, null]]; + } } throw new Error( "Unexpected reputationCheckVerdict: " + @@ -944,6 +972,8 @@ DownloadsViewUI.DownloadElementShell.prototype = { confirmUnblock(window, dialogType) { lazy.DownloadsCommon.confirmUnblockDownload({ verdict: this.download.error.reputationCheckVerdict, + becauseBlockedByReputationCheck: + this.download.error.becauseBlockedByReputationCheck, window, dialogType, }) @@ -990,6 +1020,7 @@ DownloadsViewUI.DownloadElementShell.prototype = { case lazy.DownloadsCommon.DOWNLOAD_FINISHED: return "downloadsCmd_open"; case lazy.DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL: + case lazy.DownloadsCommon.DOWNLOAD_BLOCKED_CONTENT_ANALYSIS: return "downloadsCmd_openReferrer"; case lazy.DownloadsCommon.DOWNLOAD_DIRTY: return "downloadsCmd_showBlockedInfo"; diff --git a/browser/components/downloads/content/downloads.css b/browser/components/downloads/content/downloads.css index baaa3e5d9fb3..ea073daecc5c 100644 --- a/browser/components/downloads/content/downloads.css +++ b/browser/components/downloads/content/downloads.css @@ -41,9 +41,10 @@ display: none; } -.download-state:not([state="6"],/* Blocked (parental) */ - [state="8"],/* Blocked (dirty) */ - [state="9"] /* Blocked (policy) */) +.download-state:not([state="6"], /* Blocked (parental) */ + [state="8"], /* Blocked (dirty) */ + [state="9"], /* Blocked (policy) */ + [state="10"] /* Blocked (content analysis) */) .downloadBlockedBadge, .download-state:not([state="-1"],/* Starting (initial) */ diff --git a/browser/components/downloads/content/downloads.js b/browser/components/downloads/content/downloads.js index f24176c74b81..7cb13f63e750 100644 --- a/browser/components/downloads/content/downloads.js +++ b/browser/components/downloads/content/downloads.js @@ -1710,6 +1710,14 @@ var DownloadsBlockedSubview = { let e = this.elements; let s = DownloadsCommon.strings; + e.deleteButton.hidden = + download.error?.becauseBlockedByContentAnalysis && + download.error?.reputationCheckVerdict === "Malware"; + + e.unblockButton.hidden = + download.error?.becauseBlockedByContentAnalysis && + download.error?.reputationCheckVerdict === "Malware"; + title.l10n ? document.l10n.setAttributes(e.title, title.l10n.id, title.l10n.args) : (e.title.textContent = title); diff --git a/browser/locales/en-US/chrome/browser/downloads/downloads.properties b/browser/locales/en-US/chrome/browser/downloads/downloads.properties index 308d9f8f0b90..5c7d4390eb17 100644 --- a/browser/locales/en-US/chrome/browser/downloads/downloads.properties +++ b/browser/locales/en-US/chrome/browser/downloads/downloads.properties @@ -34,6 +34,8 @@ blockedMalware=This file contains a virus or malware. blockedPotentiallyUnwanted=This file may harm your computer. blockedPotentiallyInsecure=File not downloaded: Potential security risk. blockedUncommon2=This file is not commonly downloaded. +blockedByContentAnalysis=This file was blocked by your organization. +warnedByContentAnalysis=This download may be unsafe and requires confirmation. # LOCALIZATION NOTE (fileMovedOrMissing): # Displayed when a complete download which is not at the original folder. @@ -46,8 +48,10 @@ fileDeleted=File deleted # LOCALIZATION NOTE (unblockHeaderUnblock, unblockHeaderOpen, # unblockTypeMalware, unblockTypePotentiallyUnwanted2, -# unblockTypeUncommon2, unblockTip2, unblockButtonOpen, -# unblockButtonUnblock, unblockButtonConfirmBlock, unblockInsecure2): +# unblockTypeContentAnalysisWarn, unblockTypeUncommon2, +# unblockTip2, unblockContentAnalysisWarnTip, unblockButtonOpen, +# unblockButtonUnblock, unblockButtonConfirmBlock, unblockInsecure2, +# unblockContentAnalysis1, unblockContentAnalysis2, unblock): # These strings are displayed in the dialog shown when the user asks a blocked # download to be unblocked. The severity of the threat is expressed in # descending order by the unblockType strings, it is higher for files detected @@ -56,12 +60,17 @@ unblockHeaderUnblock=Are you sure you want to allow this download? unblockHeaderOpen=Are you sure you want to open this file? unblockTypeMalware=This file contains a virus or other malware that will harm your computer. unblockTypePotentiallyUnwanted2=This file is disguised as a helpful download, but it can make unexpected changes to your programs and settings. +unblockTypeContentAnalysisWarn=Your organization uses data-loss prevention software that has flagged this content as unsafe. unblockTypeUncommon2=This file is not commonly downloaded and may not be safe to open. It may contain a virus or make unexpected changes to your programs and settings. unblockInsecure2=The download is offered over HTTP even though the current document was delivered over a secure HTTPS connection. If you proceed, the download may be corrupted or tampered with during the download process. unblockTip2=You can search for an alternate download source or try again later. +unblockContentAnalysisWarnTip=You can choose to download it anyway. unblockButtonOpen=Open unblockButtonUnblock=Allow download unblockButtonConfirmBlock=Remove file +unblockContentAnalysis1=Your organization uses data-loss prevention software that has blocked this download. +unblockContentAnalysis2=This decision can only be overridden by your organization. +unblockContentAnalysisTip=You may continue your download or cancel it. # LOCALIZATION NOTE (sizeWithUnits): # %1$S is replaced with the size number, and %2$S with the measurement unit. diff --git a/toolkit/components/downloads/DownloadCore.sys.mjs b/toolkit/components/downloads/DownloadCore.sys.mjs index 3ecd6a777a30..381225321cf1 100644 --- a/toolkit/components/downloads/DownloadCore.sys.mjs +++ b/toolkit/components/downloads/DownloadCore.sys.mjs @@ -370,10 +370,13 @@ Download.prototype = { ); } - if (this.error && this.error.becauseBlockedByReputationCheck) { + if ( + this.error?.becauseBlockedByReputationCheck || + this.error?.becauseBlockedByContentAnalysis + ) { return Promise.reject( new DownloadError({ - message: "Cannot start after being blocked by a reputation check.", + message: "Cannot start after being blocked by a safety check.", }) ); } @@ -708,6 +711,10 @@ Download.prototype = { return this._promiseUnblock; } + if (this.error?.becauseBlockedByContentAnalysis) { + this.respondToContentAnalysisWarnWithAllow(); + } + if (!this.hasBlockedData) { return Promise.reject( new Error("unblock may only be called on Downloads with blocked data.") @@ -716,7 +723,9 @@ Download.prototype = { this._promiseUnblock = (async () => { try { - await IOUtils.move(this.target.partFilePath, this.target.path); + if (this.target.partFilePath) { + await IOUtils.move(this.target.partFilePath, this.target.path); + } await this.target.refresh(); } catch (ex) { await this.refresh(); @@ -733,6 +742,44 @@ Download.prototype = { return this._promiseUnblock; }, + /** + * Indicates that the download should be allowed. Will do nothing + * if content analysis was not used. + */ + respondToContentAnalysisWarnWithAllow() { + if (this.error?.contentAnalysisWarnRequestToken) { + lazy.DownloadIntegration.getContentAnalysisService().respondToWarnDialog( + this.error.contentAnalysisWarnRequestToken, + true + ); + this.error.contentAnalysisWarnRequestToken = undefined; + } + }, + + /** + * Indicates that the download should be blocked. Will do nothing + * if content analysis was not used. + */ + async respondToContentAnalysisWarnWithBlock() { + if (this.error?.contentAnalysisWarnRequestToken) { + lazy.DownloadIntegration.getContentAnalysisService().respondToWarnDialog( + this.error.contentAnalysisWarnRequestToken, + false + ); + this.error.contentAnalysisWarnRequestToken = undefined; + if (!this.target.partFilePath) { + // Callers will be finalizing the download after this. + // But if the download happened in place, we need to + // remove the final target file. + try { + await this.saver.removeData(true); + } catch (ex) { + console.error(ex); + } + } + } + }, + /** * Confirms that a blocked download should be cleaned up. * @@ -773,6 +820,9 @@ Download.prototype = { } this._promiseConfirmBlock = (async () => { + if (this.error?.becauseBlockedByContentAnalysis) { + await this.respondToContentAnalysisWarnWithBlock(); + } // This call never throws exceptions. If the removal fails, the blocked // data remains stored on disk in the ".part" file. await this.saver.removeData(); @@ -1854,7 +1904,8 @@ export var DownloadError = function (aProperties) { } else if ( aProperties.becauseBlocked || aProperties.becauseBlockedByParentalControls || - aProperties.becauseBlockedByReputationCheck + aProperties.becauseBlockedByReputationCheck || + aProperties.becauseBlockedByContentAnalysis ) { this.message = "Download blocked."; } else { @@ -1882,6 +1933,12 @@ export var DownloadError = function (aProperties) { this.becauseBlocked = true; this.becauseBlockedByReputationCheck = true; this.reputationCheckVerdict = aProperties.reputationCheckVerdict || ""; + } else if (aProperties.becauseBlockedByContentAnalysis) { + this.becauseBlocked = true; + this.becauseBlockedByContentAnalysis = true; + this.contentAnalysisWarnRequestToken = + aProperties.contentAnalysisWarnRequestToken; + this.reputationCheckVerdict = aProperties.reputationCheckVerdict; } else if (aProperties.becauseBlocked) { this.becauseBlocked = true; } @@ -1939,6 +1996,11 @@ DownloadError.prototype = { */ becauseBlockedByReputationCheck: false, + /** + * Indicates the download was blocked by a local content analysis tool. + */ + becauseBlockedByContentAnalysis: false, + /** * If becauseBlockedByReputationCheck is true, indicates the detailed reason * why the download was blocked, according to the "BLOCK_VERDICT_" constants. @@ -2000,6 +2062,7 @@ DownloadError.fromSerializable = function (aSerializable) { property != "becauseBlocked" && property != "becauseBlockedByParentalControls" && property != "becauseBlockedByReputationCheck" && + property != "becauseBlockedByContentAnalysis" && property != "reputationCheckVerdict" ); @@ -2573,6 +2636,8 @@ DownloadCopySaver.prototype = { * @rejects DownloadError if the download should be blocked. */ async _checkReputationAndMove(aSetPropertiesFn) { + const REPUTATION_CHECK = 0; + const CONTENT_ANALYSIS_CHECK = 1; /** * Maps nsIApplicationReputationService verdicts with the DownloadError ones. */ @@ -2587,25 +2652,108 @@ DownloadCopySaver.prototype = { DownloadError.BLOCK_VERDICT_MALWARE, }; + let checkContentAnalysis = download => { + // Start an asynchronous content analysis check. + return lazy.DownloadIntegration.shouldBlockForContentAnalysis( + download + ).then(result => { + result.check = CONTENT_ANALYSIS_CHECK; + return result; + }); + }; + + let checkReputation = download => { + // Start an asynchronous reputation check. + return lazy.DownloadIntegration.shouldBlockForReputationCheck( + download + ).then(result => { + result.check = REPUTATION_CHECK; + return result; + }); + }; + + let hasMostRestrictiveResult = ([result1, result2]) => { + // Verdicts are sorted from least-to-most restrictive. However, a result that + // shouldBlock is always more restrictive than one that does not. Since + // reputation allows shouldBlock to be overridden by prefs but content + // analysis does not, we need to be careful of that. + if (result1.shouldBlock && !result2.shouldBlock) { + return result1; + } + if (result2.shouldBlock) { + return result2; + } + // Verdicts are in a pre-defined order (see nsIApplicationReputationService), + // so find the most restrictive one. + const verdictToRestrictiveness = { + [Ci.nsIApplicationReputationService.VERDICT_SAFE]: 0, + [Ci.nsIApplicationReputationService.VERDICT_POTENTIALLY_UNWANTED]: 1, + [Ci.nsIApplicationReputationService.VERDICT_UNCOMMON]: 2, + [Ci.nsIApplicationReputationService.VERDICT_DANGEROUS_HOST]: 3, + [Ci.nsIApplicationReputationService.VERDICT_DANGEROUS]: 4, + }; + return verdictToRestrictiveness[result1.verdict] > + verdictToRestrictiveness[result2.verdict] + ? result1 + : result2; + }; + let download = this.download; let targetPath = this.download.target.path; let partFilePath = this.download.target.partFilePath; - let { shouldBlock, verdict } = - await lazy.DownloadIntegration.shouldBlockForReputationCheck(download); - let downloadErrorVerdict = kVerdictMap[verdict] || ""; - if (shouldBlock) { - Glean.downloads.userActionOnBlockedDownload[ - downloadErrorVerdict - ].accumulateSingleSample(0); + let reputationPromise = checkReputation(download); + let caPromise = checkContentAnalysis(download); + + let permissionResult = await Promise.any([ + reputationPromise, + caPromise, + ]).then(async result => { + // If the first result is the most restrictive one, we can return it + // immediately. + if ( + result.shouldBlock && + result.verdict == Ci.nsIApplicationReputationService.VERDICT_DANGEROUS + ) { + return result; + } + // Otherwise wait for both results and compare them. + return await Promise.all([reputationPromise, caPromise]).then( + hasMostRestrictiveResult + ); + }); + + let downloadErrorVerdict = kVerdictMap[permissionResult.verdict] || ""; + permissionResult.verdict = downloadErrorVerdict; + if (permissionResult.shouldBlock) { + if (permissionResult.check === REPUTATION_CHECK) { + Glean.downloads.userActionOnBlockedDownload[ + downloadErrorVerdict + ].accumulateSingleSample(0); + } let newProperties = { progress: 100, hasPartialData: false }; // We will remove the potentially dangerous file if instructed by // DownloadIntegration. We will always remove the file when the // download did not use a partial file path, meaning it - // currently has its final filename. - if (!lazy.DownloadIntegration.shouldKeepBlockedData() || !partFilePath) { + // currently has its final filename, or if it was blocked by + // content analysis. + let neverRemoveData = false; + let alwaysRemoveData = false; + if (permissionResult.check === CONTENT_ANALYSIS_CHECK) { + if (downloadErrorVerdict === DownloadError.BLOCK_VERDICT_MALWARE) { + alwaysRemoveData = true; + } else { + neverRemoveData = true; + } + } + let removeData = + !neverRemoveData && + (alwaysRemoveData || + !lazy.DownloadIntegration.shouldKeepBlockedData() || + !partFilePath); + if (removeData) { await this.removeData(!partFilePath); } else { newProperties.hasBlockedData = true; @@ -2613,10 +2761,19 @@ DownloadCopySaver.prototype = { aSetPropertiesFn(newProperties); - throw new DownloadError({ - becauseBlockedByReputationCheck: true, - reputationCheckVerdict: downloadErrorVerdict, - }); + if (permissionResult.check == REPUTATION_CHECK) { + throw new DownloadError({ + becauseBlockedByReputationCheck: true, + reputationCheckVerdict: downloadErrorVerdict, + }); + } else { + throw new DownloadError({ + becauseBlockedByContentAnalysis: true, + reputationCheckVerdict: downloadErrorVerdict, + contentAnalysisWarnRequestToken: + permissionResult.contentAnalysisWarnRequestToken, + }); + } } if (partFilePath) { diff --git a/toolkit/components/downloads/DownloadHistory.sys.mjs b/toolkit/components/downloads/DownloadHistory.sys.mjs index b2d43f5587ca..6b381d65efde 100644 --- a/toolkit/components/downloads/DownloadHistory.sys.mjs +++ b/toolkit/components/downloads/DownloadHistory.sys.mjs @@ -12,6 +12,7 @@ */ import { DownloadList } from "resource://gre/modules/DownloadList.sys.mjs"; +import { DownloadError } from "resource://gre/modules/DownloadCore.sys.mjs"; const lazy = {}; @@ -32,6 +33,7 @@ const METADATA_STATE_CANCELED = 3; const METADATA_STATE_PAUSED = 4; const METADATA_STATE_BLOCKED_PARENTAL = 6; const METADATA_STATE_DIRTY = 8; +const METADATA_STATE_BLOCKED_CONTENT_ANALYSIS = 9; /** * Provides methods to retrieve downloads from previous sessions and store @@ -125,6 +127,12 @@ export let DownloadHistory = { state = METADATA_STATE_BLOCKED_PARENTAL; } else if (download.error.becauseBlockedByReputationCheck) { state = METADATA_STATE_DIRTY; + } else if (download.error.becauseBlockedByContentAnalysis) { + state = + download.error.reputationCheckVerdict === + DownloadError.BLOCK_VERDICT_MALWARE + ? METADATA_STATE_BLOCKED_CONTENT_ANALYSIS + : METADATA_STATE_DIRTY; } else { state = METADATA_STATE_FAILED; } @@ -433,6 +441,11 @@ class HistoryDownload { this.error = { message: "History download failed." }; } else if (metaData.state == METADATA_STATE_BLOCKED_PARENTAL) { this.error = { becauseBlockedByParentalControls: true }; + } else if (metaData.state == METADATA_STATE_BLOCKED_CONTENT_ANALYSIS) { + this.error = { + becauseBlockedByContentAnalysis: true, + reputationCheckVerdict: metaData.reputationCheckVerdict || "", + }; } else if (metaData.state == METADATA_STATE_DIRTY) { this.error = { becauseBlockedByReputationCheck: true, @@ -503,6 +516,17 @@ class HistoryDownload { this.deleted = true; await this.refresh(); } + + /** + * This method mimicks the "respondToContentAnalysisWarnWithBlock" + * method of session downloads. + */ + async respondToContentAnalysisWarnWithBlock() { + // A history download cannot be pending a content + // analysis response (since it doesn't persist after Firefox + // is closed), so just do nothing. + console.warn("attempted to block via Content Analysis a history download"); + } } /** diff --git a/toolkit/components/downloads/DownloadIntegration.sys.mjs b/toolkit/components/downloads/DownloadIntegration.sys.mjs index 1344a97425db..473902eaa6a3 100644 --- a/toolkit/components/downloads/DownloadIntegration.sys.mjs +++ b/toolkit/components/downloads/DownloadIntegration.sys.mjs @@ -96,6 +96,7 @@ const kSaveDelayMs = 1500; */ const kObserverTopics = [ "quit-application-requested", + "quit-application-granted", "offline-requested", "last-pb-context-exiting", "last-pb-context-exited", @@ -423,6 +424,151 @@ export var DownloadIntegration = { }); }, + getContentAnalysisService() { + // Do not use a lazy service getter for this, because tests set up different mocks, + // so if multiple tests run that call into this we can end up calling into an old mock. + return Cc["@mozilla.org/contentanalysis;1"].getService( + Ci.nsIContentAnalysis + ); + }, + + async shouldBlockForContentAnalysis(download) { + const contentAnalysis = this.getContentAnalysisService(); + + if (!contentAnalysis.isActive) { + return { + verdict: Ci.nsIApplicationReputationService.VERDICT_SAFE, + shouldBlock: false, + }; + } + + // For PDF files loaded in pdf.js the originalUrl is the original URL + // where the PDF was loaded from, and the url is the URL of the pdf.js + // resource. + let downloadUrl = download.source.originalUrl ?? download.source.url; + let resources = [ + { + url: downloadUrl, + type: Ci.nsIClientDownloadResource.DOWNLOAD_URL, + }, + ]; + + let redirects = download.saver.getRedirects(); + if (redirects) { + for (let redirect of redirects) { + resources.push({ + url: redirect.referrerURI, + type: Ci.nsIClientDownloadResource.DOWNLOAD_REDIRECT, + }); + } + } + + // source.referrerInfo is a string or nsIReferrerInfo that + // represents the download referrer. May be null. + if (download.source.referrerInfo) { + const url = + download.source.referrerInfo instanceof Ci.nsIReferrerInfo + ? download.source.referrerInfo.originalReferrer?.spec + : download.source.referrerInfo; + if (url) { + resources.push({ + url, + type: Ci.nsIClientDownloadResource.TAB_URL, + }); + } + } + + let url = lazy.NetUtil.newURI(downloadUrl); + let fileNameForDisplay = download.target.path; + try { + // Try to get a prettier name + let file = new lazy.FileUtils.File(download.target.path); + fileNameForDisplay = file.displayName; + } catch (ex) { + // oh well + } + const requestToken = Services.uuid.generateUUID().toString(); + let warnResponseObserver = undefined; + // Set up a separate promise to wait specifically for a WARN + // response (if it comes) while we also wait for a final response. + // This is necessary because if the agent sends a WARN response, + // it doesn't count as a real response, and the Content Analysis code + // won't respond to the callback until respondToWarnDialog() is called. + const warnResultPromise = new Promise(resolve => { + warnResponseObserver = function (subject, topic, _data) { + if (topic == "dlp-response") { + /** @type nsIContentAnalysisResponse */ + let response = subject; + if ( + response.requestToken === requestToken && + response.action === Ci.nsIContentAnalysisResponse.eWarn + ) { + resolve({ + isContentAnalysisWarn: true, + verdict: + Ci.nsIApplicationReputationService.VERDICT_POTENTIALLY_UNWANTED, + contentAnalysisWarnRequestToken: requestToken, + shouldBlock: true, + }); + } + } + }; + Services.obs.addObserver(warnResponseObserver, "dlp-response"); + }); + let finalResultPromise = contentAnalysis + .analyzeContentRequests( + [ + { + analysisType: Ci.nsIContentAnalysisRequest.eFileDownloaded, + operationTypeForDisplay: Ci.nsIContentAnalysisRequest.eDownload, + fileNameForDisplay, + // "Save As" downloads do not have a browsing context + reason: + download.source.browsingContextId === 0 + ? Ci.nsIContentAnalysisRequest.eSaveAsDownload + : Ci.nsIContentAnalysisRequest.eNormalDownload, + resources, + requestToken, + url, + filePath: download.target.path, + // When doing a download analysis, the Content Analysis code won't + // display dialogs in the window, but the code still wants a + // content window and will get the topChromeWindow to show + // a notification. + windowGlobalParent: BrowsingContext.get( + download.source.browsingContextId + )?.topWindowContext, + sha256Digest: download.saver.getSha256Hash(), + }, + ], + /* autoAcknowledge*/ true + ) + .then(response => { + return { + verdict: response.shouldAllowContent + ? Ci.nsIApplicationReputationService.VERDICT_SAFE + : Ci.nsIApplicationReputationService.VERDICT_DANGEROUS, + shouldBlock: !response.shouldAllowContent, + contentAnalysisWarnRequestToken: undefined, + }; + }); + try { + let finalOrWarnResult = await Promise.race([ + finalResultPromise, + warnResultPromise, + ]); + return finalOrWarnResult; + } catch (e) { + console.error(e); + return { + verdict: Ci.nsIApplicationReputationService.VERDICT_DANGEROUS, + shouldBlock: true, + }; + } finally { + Services.obs.removeObserver(warnResponseObserver, "dlp-response"); + } + }, + /** * Checks whether downloaded files should be marked as coming from * Internet Zone. @@ -961,6 +1107,15 @@ var DownloadObserver = { */ _privateInProgressDownloads: new Set(), + /** + * Set of downloads that have finished but have gotten a content analysis + * WARN response. These downloads need to be canceled when quitting, because + * the next time we start Firefox the content analysis agent may not have + * the same context as the one that was running when it analyzed the + * download. + */ + _contentAnalysisWarnInProgressDownloads: new Set(), + /** * Set that contains the downloads that have been canceled when going offline * or to sleep. These are started again when returning online or waking. This @@ -991,6 +1146,11 @@ var DownloadObserver = { }, onDownloadChanged: aDownload => { if (aDownload.stopped) { + if (aDownload.error?.contentAnalysisWarnRequestToken) { + this._contentAnalysisWarnInProgressDownloads.add(aDownload); + } else { + this._contentAnalysisWarnInProgressDownloads.delete(aDownload); + } downloadsSet.delete(aDownload); } else { downloadsSet.add(aDownload); @@ -998,6 +1158,7 @@ var DownloadObserver = { }, onDownloadRemoved: aDownload => { downloadsSet.delete(aDownload); + this._contentAnalysisWarnInProgressDownloads.delete(aDownload); // The download must also be removed from the canceled when offline set. this._canceledOfflineDownloads.delete(aDownload); }, @@ -1065,12 +1226,41 @@ var DownloadObserver = { observe: function DO_observe(aSubject, aTopic, aData) { let downloadsCount; switch (aTopic) { - case "quit-application-requested": + case "quit-application-requested": { downloadsCount = this._publicInProgressDownloads.size + - this._privateInProgressDownloads.size; + this._privateInProgressDownloads.size + + this._contentAnalysisWarnInProgressDownloads.size; this._confirmCancelDownloads(aSubject, downloadsCount, "ON_QUIT"); break; + } + case "quit-application-granted": { + let blockPromises = []; + for (let download of this._contentAnalysisWarnInProgressDownloads) { + blockPromises.push( + (async () => { + await download.respondToContentAnalysisWarnWithBlock(); + await download.finalize(true); + })() + ); + } + if (blockPromises.length) { + // Wait for all the downloads to be blocked (and the files deleted) + // before proceeding with the quit. + let promiseDone = false; + Promise.all(blockPromises).finally(() => { + promiseDone = true; + }); + Services.tm.spinEventLoopUntil( + "DownloadIntegration.sys.mjs:DI_observe_quit-application-granted", + () => { + return promiseDone; + } + ); + } + this._contentAnalysisWarnInProgressDownloads.clear(); + break; + } case "offline-requested": downloadsCount = this._publicInProgressDownloads.size + diff --git a/toolkit/components/reputationservice/nsIApplicationReputation.idl b/toolkit/components/reputationservice/nsIApplicationReputation.idl index 50a923ad2dd6..606620be3a27 100644 --- a/toolkit/components/reputationservice/nsIApplicationReputation.idl +++ b/toolkit/components/reputationservice/nsIApplicationReputation.idl @@ -20,6 +20,8 @@ interface nsIReferrerInfo; interface nsIApplicationReputationService : nsISupports { /** * Indicates the reason for the application reputation block. + * These values should not be modified as they match the values in + * ClientDownloadResponse.Verdict in csd.proto. */ const unsigned long VERDICT_SAFE = 0; const unsigned long VERDICT_DANGEROUS = 1;