Bug 1965504 part 5 - integrate Content Analysis into Downloads a=diannaS

The actual call to content analysis is in DownloadCore.sys.mjs's
_checkReputationAndMove() method.

Original Revision: https://phabricator.services.mozilla.com/D251881

Differential Revision: https://phabricator.services.mozilla.com/D258207
This commit is contained in:
Greg Stoll
2025-07-23 17:30:23 +00:00
committed by dsmith@mozilla.com
parent 143dc7420f
commit 099ed944b9
9 changed files with 493 additions and 36 deletions

View File

@@ -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:
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");
}
},

View File

@@ -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.warnedByContentAnalysis,
[s.unblockTypeContentAnalysisWarn, s.unblockContentAnalysisWarnTip],
];
case lazy.Downloads.Error.BLOCK_VERDICT_MALWARE:
if (this.download.error.becauseBlockedByReputationCheck) {
return [s.blockedMalware, [s.unblockTypeMalware, s.unblockTip2]];
case lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM:
}
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: {
@@ -915,6 +942,7 @@ DownloadsViewUI.DownloadElementShell.prototype = {
};
return [{ l10n: title }, [{ l10n: details }, null]];
}
}
throw new Error(
"Unexpected reputationCheckVerdict: " +
this.download.error.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";

View File

@@ -43,7 +43,8 @@
.download-state:not([state="6"], /* Blocked (parental) */
[state="8"], /* Blocked (dirty) */
[state="9"] /* Blocked (policy) */)
[state="9"], /* Blocked (policy) */
[state="10"] /* Blocked (content analysis) */)
.downloadBlockedBadge,
.download-state:not([state="-1"],/* Starting (initial) */

View File

@@ -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);

View File

@@ -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.

View File

@@ -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 {
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) {
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);
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) {

View File

@@ -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");
}
}
/**

View File

@@ -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 +

View File

@@ -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;