summaryrefslogtreecommitdiffstats
path: root/browser/components/attribution/ProvenanceData.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/attribution/ProvenanceData.sys.mjs')
-rw-r--r--browser/components/attribution/ProvenanceData.sys.mjs544
1 files changed, 544 insertions, 0 deletions
diff --git a/browser/components/attribution/ProvenanceData.sys.mjs b/browser/components/attribution/ProvenanceData.sys.mjs
new file mode 100644
index 0000000000..128b0214aa
--- /dev/null
+++ b/browser/components/attribution/ProvenanceData.sys.mjs
@@ -0,0 +1,544 @@
+/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ AttributionIOUtils: "resource:///modules/AttributionCode.sys.mjs",
+});
+
+let gReadZoneIdPromise = null;
+let gTelemetryPromise = null;
+
+export var ProvenanceData = {
+ /**
+ * Clears cached code/Promises. For testing only.
+ */
+ _clearCache() {
+ if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ gReadZoneIdPromise = null;
+ gTelemetryPromise = null;
+ }
+ },
+
+ /**
+ * Returns an nsIFile for the file containing the zone identifier-based
+ * provenance data. This currently only exists on Windows. On other platforms,
+ * this will return null.
+ */
+ get zoneIdProvenanceFile() {
+ if (AppConstants.platform == "win") {
+ let file = Services.dirsvc.get("GreD", Ci.nsIFile);
+ file.append("zoneIdProvenanceData");
+ return file;
+ }
+ return null;
+ },
+
+ /**
+ * Loads the provenance data that the installer copied (along with some
+ * metadata) from the Firefox installer used to create the current
+ * installation.
+ *
+ * If it doesn't already exist, creates a global Promise that loads the data
+ * from the file and caches it. Subsequent calls get the same Promise. There
+ * is no support for re-reading the file from the disk because there is no
+ * good reason that the contents of the file should change.
+ *
+ * Only expected contents will be pulled out of the file. This does not
+ * extract arbitrary, unexpected data. Data will be validated, to the extent
+ * possible. Most possible data returned has a potential key indicating that
+ * we read an unexpected value out of the file.
+ *
+ * @returns `null` on unsupported OSs. Otherwise, an object with these
+ * possible keys:
+ * `readProvenanceError`
+ * Will be present if there was a problem when Firefox tried to read the
+ * file that ought to have been written by the installer. Possible values
+ * are:
+ * `noSuchFile`, `readError`, `parseError`
+ * If this key is present, no other keys will be present.
+ * `fileSystem`
+ * What filesystem the installer was on. If available, this will be a
+ * string returned from `GetVolumeInformationByHandleW` via its
+ * `lpFileSystemNameBuffer` parameter. Possible values are:
+ * `NTFS`, `FAT32`, `other`, `missing`, `readIniError`
+ * `other` will be used if the value read does not match one of the
+ * expected file systems.
+ * `missing` will be used if the file didn't contain `fileSystem` or
+ * `readFsError` information.
+ * `readIniError` will be used if an error is encountered when reading
+ * the key from the file in the installation directory.
+ * `readFsError`
+ * The reason why the installer file system could not be determined. Will
+ * be present if `readProvenanceError` and `fileSystem` are not. Possible
+ * values are:
+ * `openFile`, `getVolInfo`, `fsUnterminated`, `getBufferSize`,
+ * `convertString`, `unexpected`, `readIniError`
+ * `unexpected` will be used if the value read from the file didn't match
+ * any of the expected values, for some reason.
+ * `missing` will be used if the file didn't contain `fileSystem` or
+ * `readFsError` information.
+ * `readIniError` will be used if an error is encountered when reading
+ * the key from the file in the installation directory.
+ * `readFsErrorCode`
+ * An integer returned by `GetLastError()` indicating, in more detail,
+ * why we failed to obtain the file system. This key may exist if
+ * `readFsError` exists.
+ * `readZoneIdError`
+ * The reason why the installer was unable to read its zone identifier
+ * ADS. Possible values are:
+ * `openFile`, `readFile`, `unexpected`, `readIniError`
+ * `unexpected` will be used if the value read from the file didn't match
+ * any of the expected values, for some reason.
+ * `readIniError` will be used if an error is encountered when reading
+ * the key from the file in the installation directory.
+ * `readZoneIdErrorCode`
+ * An integer returned by `GetLastError()` indicating, in more detail,
+ * why we failed to read the zone identifier ADS. This key may exist if
+ * `readZoneIdError` exists.
+ * `zoneIdFileSize`
+ * This key should exist if Firefox successfully read the file in the
+ * installation directory and the installer successfully opened the ADS.
+ * If the installer failed to get the size of the ADS prior to reading
+ * it, this will be `unknown`. If the installer was able to get the ADS
+ * size, this will be an integer describing how many bytes long it was.
+ * If this value in installation directory's file isn't `unknown` or an
+ * integer, this will be `unexpected`. If an error is encountered when
+ * reading the key from the file in the installation directory, this will
+ * be `readIniError`.
+ * `zoneIdBufferLargeEnough`
+ * This key should exist if Firefox successfully read the file in the
+ * installation directory and the installer successfully opened the ADS.
+ * Indicates whether the zone identifier ADS size was bigger than the
+ * maximum size that the installer will read from it. If we failed to
+ * determine the ADS size, this will be `unknown`. If the installation
+ * directory's file contains an invalid value, this will be `unexpected`.
+ * If an error is encountered when reading the key from the file in the
+ * installation directory, this will be `readIniError`.
+ * Otherwise, this will be a boolean indicating whether or not the buffer
+ * was large enough to fit the ADS data into.
+ * `zoneIdTruncated`
+ * This key should exist if Firefox successfully read the file in the
+ * installation directory and the installer successfully read the ADS.
+ * Indicates whether or not we read through the end of the ADS data when
+ * we copied it. If the installer failed to determine this, this value
+ * will be `unknown`. If the installation directory's file contains an
+ * invalid value, this will be `unexpected`. If an error is encountered
+ * when reading the key from the file in the installation directory, this
+ * will be `readIniError`. Otherwise, this will be a boolean value
+ * indicating whether or not the data that we copied was truncated.
+ * `zoneId`
+ * The Security Zone that the Zone Identifier data indicates that
+ * installer was downloaded from. See this documentation:
+ * https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms537183(v=vs.85)
+ * This key will be present if `readProvenanceError` and
+ * `readZoneIdError` are not. It will either be a valid zone ID (an
+ * integer between 0 and 4, inclusive), or else it will be `unexpected`,
+ * `missing`, or `readIniError`.
+ * `referrerUrl`
+ * The URL of the download referrer. This key will be present if
+ * `readProvenanceError` and `readZoneIdError` are not. It will either be
+ * a `URL` object, or else it will be `unexpected`, `missing`, or
+ * `readIniError`.
+ * `referrerUrlIsMozilla`
+ * This key will be present if `ReferrerUrl` is a `URL` object. It will
+ * be `true`` if the download referrer appears to be a Mozilla URL.
+ * Otherwise it will be `false`.
+ * `hostUrl`
+ * The URL of the download source. This key will be present if
+ * `readProvenanceError` and `readZoneIdError` are not. It will either be
+ * a `URL` object, or else it will be `unexpected`, `missing`, or
+ * `readIniError`
+ * `hostUrlIsMozilla`
+ * This key will be present if `HostUrl` is a `URL` object. It will
+ * be `true`` if the download source appears to be a Mozilla URL.
+ * Otherwise it will be `false`.
+ */
+ async readZoneIdProvenanceFile() {
+ if (gReadZoneIdPromise) {
+ return gReadZoneIdPromise;
+ }
+ gReadZoneIdPromise = (async () => {
+ let file = this.zoneIdProvenanceFile;
+ if (!file) {
+ return null;
+ }
+ let iniData;
+ try {
+ iniData = await lazy.AttributionIOUtils.readUTF8(file.path);
+ } catch (ex) {
+ if (DOMException.isInstance(ex) && ex.name == "NotFoundError") {
+ return { readProvenanceError: "noSuchFile" };
+ }
+ return { readProvenanceError: "readError" };
+ }
+
+ let ini;
+ try {
+ // We would rather use asynchronous I/O, so we are going to read the
+ // file with IOUtils and then pass the result into the INI parser
+ // rather than just giving the INI parser factory the file.
+ ini = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
+ .getService(Ci.nsIINIParserFactory)
+ .createINIParser(null);
+ ini.initFromString(iniData);
+ } catch (ex) {
+ return { readProvenanceError: "parseError" };
+ }
+
+ const unexpectedValueError = "unexpected";
+ const missingKeyError = "missing";
+ const readIniError = "readIniError";
+ const possibleIniErrors = [missingKeyError, readIniError];
+
+ // Format is {"IniSectionName": {"IniKeyName": "IniValue"}}
+ let iniValues = {
+ Mozilla: {
+ fileSystem: null,
+ readFsError: null,
+ readFsErrorCode: null,
+ readZoneIdError: null,
+ readZoneIdErrorCode: null,
+ zoneIdFileSize: null,
+ zoneIdBufferLargeEnough: null,
+ zoneIdTruncated: null,
+ },
+ ZoneTransfer: {
+ ZoneId: null,
+ ReferrerUrl: null,
+ HostUrl: null,
+ },
+ };
+
+ // The ini reader interface is a little weird in that if we just try to
+ // read a value from a known section/key and the section/key doesn't
+ // exist, we just get a generic error rather than an indication that
+ // the section/key doesn't exist. To distinguish missing keys from any
+ // other potential errors, we are going to enumerate the sections and
+ // keys use that to determine if we should try to read from them.
+ let existingSections;
+ try {
+ existingSections = Array.from(ini.getSections());
+ } catch (ex) {
+ return { readProvenanceError: "parseError" };
+ }
+ for (const section in iniValues) {
+ if (!existingSections.includes(section)) {
+ for (const key in iniValues[section]) {
+ iniValues[section][key] = missingKeyError;
+ }
+ continue;
+ }
+
+ let existingKeys;
+ try {
+ existingKeys = Array.from(ini.getKeys(section));
+ } catch (ex) {
+ for (const key in iniValues[section]) {
+ iniValues[section][key] = readIniError;
+ }
+ continue;
+ }
+
+ for (const key in iniValues[section]) {
+ if (!existingKeys.includes(key)) {
+ iniValues[section][key] = missingKeyError;
+ continue;
+ }
+
+ let value;
+ try {
+ value = ini.getString(section, key).trim();
+ } catch (ex) {
+ value = readIniError;
+ }
+ iniValues[section][key] = value;
+ }
+ }
+
+ // This helps with how verbose the validation gets.
+ const fileSystem = iniValues.Mozilla.fileSystem;
+ const readFsError = iniValues.Mozilla.readFsError;
+ const readFsErrorCode = iniValues.Mozilla.readFsErrorCode;
+ const readZoneIdError = iniValues.Mozilla.readZoneIdError;
+ const readZoneIdErrorCode = iniValues.Mozilla.readZoneIdErrorCode;
+ const zoneIdFileSize = iniValues.Mozilla.zoneIdFileSize;
+ const zoneIdBufferLargeEnough = iniValues.Mozilla.zoneIdBufferLargeEnough;
+ const zoneIdTruncated = iniValues.Mozilla.zoneIdTruncated;
+ const zoneId = iniValues.ZoneTransfer.ZoneId;
+ const referrerUrl = iniValues.ZoneTransfer.ReferrerUrl;
+ const hostUrl = iniValues.ZoneTransfer.HostUrl;
+
+ let returnObject = {};
+
+ // readFsError, readFsErrorCode, fileSystem
+ const validReadFsErrors = [
+ "openFile",
+ "getVolInfo",
+ "fsUnterminated",
+ "getBufferSize",
+ "convertString",
+ ];
+ // These must be upper case
+ const validFileSystemValues = ["NTFS", "FAT32"];
+ if (fileSystem == missingKeyError && readFsError != missingKeyError) {
+ if (
+ possibleIniErrors.includes(readFsError) ||
+ validReadFsErrors.includes(readFsError)
+ ) {
+ returnObject.readFsError = readFsError;
+ } else {
+ returnObject.readFsError = unexpectedValueError;
+ }
+ if (readFsErrorCode != missingKeyError) {
+ let code = parseInt(readFsErrorCode, 10);
+ if (!isNaN(code)) {
+ returnObject.readFsErrorCode = code;
+ }
+ }
+ } else if (possibleIniErrors.includes(fileSystem)) {
+ returnObject.fileSystem = fileSystem;
+ } else if (validFileSystemValues.includes(fileSystem.toUpperCase())) {
+ returnObject.fileSystem = fileSystem.toUpperCase();
+ } else {
+ returnObject.fileSystem = "other";
+ }
+
+ // zoneIdFileSize
+ if (zoneIdFileSize == missingKeyError) {
+ // We don't include this one if it's missing.
+ } else if (
+ zoneIdFileSize == readIniError ||
+ zoneIdFileSize == "unknown"
+ ) {
+ returnObject.zoneIdFileSize = zoneIdFileSize;
+ } else {
+ let size = parseInt(zoneIdFileSize, 10);
+ if (isNaN(size)) {
+ returnObject.zoneIdFileSize = unexpectedValueError;
+ } else {
+ returnObject.zoneIdFileSize = size;
+ }
+ }
+
+ // zoneIdBufferLargeEnough
+ if (zoneIdBufferLargeEnough == missingKeyError) {
+ // We don't include this one if it's missing.
+ } else if (
+ zoneIdBufferLargeEnough == readIniError ||
+ zoneIdBufferLargeEnough == "unknown"
+ ) {
+ returnObject.zoneIdBufferLargeEnough = zoneIdBufferLargeEnough;
+ } else if (zoneIdBufferLargeEnough.toLowerCase() == "true") {
+ returnObject.zoneIdBufferLargeEnough = true;
+ } else if (zoneIdBufferLargeEnough.toLowerCase() == "false") {
+ returnObject.zoneIdBufferLargeEnough = false;
+ } else {
+ returnObject.zoneIdBufferLargeEnough = unexpectedValueError;
+ }
+
+ // zoneIdTruncated
+ if (zoneIdTruncated == missingKeyError) {
+ // We don't include this one if it's missing.
+ } else if (
+ zoneIdTruncated == readIniError ||
+ zoneIdTruncated == "unknown"
+ ) {
+ returnObject.zoneIdTruncated = zoneIdTruncated;
+ } else if (zoneIdTruncated.toLowerCase() == "true") {
+ returnObject.zoneIdTruncated = true;
+ } else if (zoneIdTruncated.toLowerCase() == "false") {
+ returnObject.zoneIdTruncated = false;
+ } else {
+ returnObject.zoneIdTruncated = unexpectedValueError;
+ }
+
+ // readZoneIdError, readZoneIdErrorCode, zoneId, referrerUrl, hostUrl,
+ // referrerUrlIsMozilla, hostUrlIsMozilla
+ const validReadZoneIdErrors = ["openFile", "readFile"];
+ if (
+ readZoneIdError != missingKeyError &&
+ zoneId == missingKeyError &&
+ referrerUrl == missingKeyError &&
+ hostUrl == missingKeyError
+ ) {
+ if (
+ possibleIniErrors.includes(readZoneIdError) ||
+ validReadZoneIdErrors.includes(readZoneIdError)
+ ) {
+ returnObject.readZoneIdError = readZoneIdError;
+ } else {
+ returnObject.readZoneIdError = unexpectedValueError;
+ }
+ if (readZoneIdErrorCode != missingKeyError) {
+ let code = parseInt(readZoneIdErrorCode, 10);
+ if (!isNaN(code)) {
+ returnObject.readZoneIdErrorCode = code;
+ }
+ }
+ } else {
+ if (possibleIniErrors.includes(zoneId)) {
+ returnObject.zoneId = zoneId;
+ } else {
+ let id = parseInt(zoneId, 10);
+ if (isNaN(id) || id < 0 || id > 4) {
+ returnObject.zoneId = unexpectedValueError;
+ } else {
+ returnObject.zoneId = id;
+ }
+ }
+
+ let isMozillaURL = url => {
+ const mozillaDomains = ["mozilla.com", "mozilla.net", "mozilla.org"];
+ for (const domain of mozillaDomains) {
+ if (url.hostname == domain) {
+ return true;
+ }
+ if (url.hostname.endsWith("." + domain)) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ if (possibleIniErrors.includes(referrerUrl)) {
+ returnObject.referrerUrl = referrerUrl;
+ } else {
+ try {
+ returnObject.referrerUrl = new URL(referrerUrl);
+ } catch (ex) {
+ returnObject.referrerUrl = unexpectedValueError;
+ }
+ if (URL.isInstance(returnObject.referrerUrl)) {
+ returnObject.referrerUrlIsMozilla = isMozillaURL(
+ returnObject.referrerUrl
+ );
+ }
+ }
+
+ if (possibleIniErrors.includes(hostUrl)) {
+ returnObject.hostUrl = hostUrl;
+ } else {
+ try {
+ returnObject.hostUrl = new URL(hostUrl);
+ } catch (ex) {
+ returnObject.hostUrl = unexpectedValueError;
+ }
+ if (URL.isInstance(returnObject.hostUrl)) {
+ returnObject.hostUrlIsMozilla = isMozillaURL(returnObject.hostUrl);
+ }
+ }
+ }
+
+ return returnObject;
+ })();
+ return gReadZoneIdPromise;
+ },
+
+ /**
+ * Only submits telemetry once, no matter how many times it is called.
+ * Has no effect on OSs where provenance data is not supported.
+ *
+ * @returns An object indicating the values submitted. Keys may not match the
+ * Scalar names since the returned object is intended to be suitable
+ * for use as a Telemetry Event's `extra` object, which has shorter
+ * limits for extra key names than the limits for Scalar names.
+ * Values will be converted to strings since Telemetry Event's
+ * `extra` objects must have string values.
+ * On platforms that do not support provenance data, this will always
+ * return an empty object.
+ */
+ async submitProvenanceTelemetry() {
+ if (gTelemetryPromise) {
+ return gTelemetryPromise;
+ }
+ gTelemetryPromise = (async () => {
+ const errorValue = "error";
+
+ let extra = {};
+
+ let provenance = await this.readZoneIdProvenanceFile();
+ if (!provenance) {
+ return extra;
+ }
+
+ let setTelemetry = (scalarName, extraKey, value) => {
+ Services.telemetry.scalarSet(scalarName, value);
+ extra[extraKey] = value.toString();
+ };
+
+ setTelemetry(
+ "attribution.provenance.data_exists",
+ "data_exists",
+ !provenance.readProvenanceError
+ );
+ if (provenance.readProvenanceError) {
+ return extra;
+ }
+
+ setTelemetry(
+ "attribution.provenance.file_system",
+ "file_system",
+ provenance.fileSystem ?? errorValue
+ );
+
+ // https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-#ERROR_FILE_NOT_FOUND
+ const ERROR_FILE_NOT_FOUND = 2;
+
+ let ads_exists =
+ !provenance.readProvenanceError &&
+ !(
+ provenance.readZoneIdError == "openFile" &&
+ provenance.readZoneIdErrorCode == ERROR_FILE_NOT_FOUND
+ );
+ setTelemetry(
+ "attribution.provenance.ads_exists",
+ "ads_exists",
+ ads_exists
+ );
+ if (!ads_exists) {
+ return extra;
+ }
+
+ setTelemetry(
+ "attribution.provenance.security_zone",
+ "security_zone",
+ "zoneId" in provenance ? provenance.zoneId.toString() : errorValue
+ );
+
+ let haveReferrerUrl = URL.isInstance(provenance.referrerUrl);
+ setTelemetry(
+ "attribution.provenance.referrer_url_exists",
+ "refer_url_exist",
+ haveReferrerUrl
+ );
+ if (haveReferrerUrl) {
+ setTelemetry(
+ "attribution.provenance.referrer_url_is_mozilla",
+ "refer_url_moz",
+ provenance.referrerUrlIsMozilla
+ );
+ }
+
+ let haveHostUrl = URL.isInstance(provenance.hostUrl);
+ setTelemetry(
+ "attribution.provenance.host_url_exists",
+ "host_url_exist",
+ haveHostUrl
+ );
+ if (haveHostUrl) {
+ setTelemetry(
+ "attribution.provenance.host_url_is_mozilla",
+ "host_url_moz",
+ provenance.hostUrlIsMozilla
+ );
+ }
+
+ return extra;
+ })();
+ return gTelemetryPromise;
+ },
+};