AboutProtectionsParent.sys.mjs (13962B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 AddonManager: "resource://gre/modules/AddonManager.sys.mjs", 11 BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", 12 FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.sys.mjs", 13 FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.sys.mjs", 14 LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", 15 LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", 16 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 17 Region: "resource://gre/modules/Region.sys.mjs", 18 }); 19 20 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { 21 return ChromeUtils.importESModule( 22 "resource://gre/modules/FxAccounts.sys.mjs" 23 ).getFxAccountsSingleton(); 24 }); 25 26 XPCOMUtils.defineLazyServiceGetter( 27 lazy, 28 "TrackingDBService", 29 "@mozilla.org/tracking-db-service;1", 30 Ci.nsITrackingDBService 31 ); 32 33 let idToTextMap = new Map([ 34 [Ci.nsITrackingDBService.TRACKERS_ID, "tracker"], 35 [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookie"], 36 [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominer"], 37 [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinter"], 38 // We map the suspicious fingerprinter to fingerprinter category to aggregate 39 // the number. 40 [Ci.nsITrackingDBService.SUSPICIOUS_FINGERPRINTERS_ID, "fingerprinter"], 41 [Ci.nsITrackingDBService.SOCIAL_ID, "social"], 42 ]); 43 44 const MONITOR_API_ENDPOINT = Services.urlFormatter.formatURLPref( 45 "browser.contentblocking.report.endpoint_url" 46 ); 47 48 const SECURE_PROXY_ADDON_ID = "secure-proxy@mozilla.com"; 49 50 const SCOPE_MONITOR = [ 51 "profile:uid", 52 "https://identity.mozilla.com/apps/monitor", 53 ]; 54 55 const SCOPE_VPN = "profile https://identity.mozilla.com/account/subscriptions"; 56 const VPN_ENDPOINT = `${Services.prefs.getStringPref( 57 "identity.fxaccounts.auth.uri" 58 )}oauth/subscriptions/active`; 59 60 // The ID of the vpn subscription, if we see this ID attached to a user's account then they have subscribed to vpn. 61 const VPN_SUB_ID = Services.prefs.getStringPref( 62 "browser.contentblocking.report.vpn_sub_id" 63 ); 64 65 // Error messages 66 const INVALID_OAUTH_TOKEN = "Invalid OAuth token"; 67 const USER_UNSUBSCRIBED_TO_MONITOR = "User is not subscribed to Monitor"; 68 const SERVICE_UNAVAILABLE = "Service unavailable"; 69 const UNEXPECTED_RESPONSE = "Unexpected response"; 70 const UNKNOWN_ERROR = "Unknown error"; 71 72 // Valid response info for successful Monitor data 73 const MONITOR_RESPONSE_PROPS = [ 74 "monitoredEmails", 75 "numBreaches", 76 "passwords", 77 "numBreachesResolved", 78 "passwordsResolved", 79 ]; 80 81 let gTestOverride = null; 82 let monitorResponse = null; 83 let entrypoint = "direct"; 84 85 export class AboutProtectionsParent extends JSWindowActorParent { 86 constructor() { 87 super(); 88 } 89 90 // Some tests wish to override certain functions with ones that mostly do nothing. 91 static setTestOverride(callback) { 92 gTestOverride = callback; 93 } 94 95 /** 96 * Fetches and validates data from the Monitor endpoint. If successful, then return 97 * expected data. Otherwise, throw the appropriate error depending on the status code. 98 * 99 * @return valid data from endpoint. 100 */ 101 async fetchUserBreachStats(token) { 102 if (monitorResponse && monitorResponse.timestamp) { 103 var timeDiff = Date.now() - monitorResponse.timestamp; 104 let oneDayInMS = 24 * 60 * 60 * 1000; 105 if (timeDiff >= oneDayInMS) { 106 monitorResponse = null; 107 } else { 108 return monitorResponse; 109 } 110 } 111 112 // Make the request 113 const headers = new Headers(); 114 headers.append("Authorization", `Bearer ${token}`); 115 const request = new Request(MONITOR_API_ENDPOINT, { headers }); 116 const response = await fetch(request); 117 118 if (response.ok) { 119 // Validate the shape of the response is what we're expecting. 120 const json = await response.json(); 121 122 // Make sure that we're getting the expected data. 123 let isValid = null; 124 for (let prop in json) { 125 isValid = MONITOR_RESPONSE_PROPS.includes(prop); 126 127 if (!isValid) { 128 break; 129 } 130 } 131 132 monitorResponse = isValid ? json : new Error(UNEXPECTED_RESPONSE); 133 if (isValid) { 134 monitorResponse.timestamp = Date.now(); 135 } 136 } else { 137 // Check the reason for the error 138 switch (response.status) { 139 case 400: 140 case 401: 141 monitorResponse = new Error(INVALID_OAUTH_TOKEN); 142 break; 143 case 404: 144 monitorResponse = new Error(USER_UNSUBSCRIBED_TO_MONITOR); 145 break; 146 case 503: 147 monitorResponse = new Error(SERVICE_UNAVAILABLE); 148 break; 149 default: 150 monitorResponse = new Error(UNKNOWN_ERROR); 151 break; 152 } 153 } 154 155 if (monitorResponse instanceof Error) { 156 throw monitorResponse; 157 } 158 return monitorResponse; 159 } 160 161 /** 162 * Retrieves login data for the user. 163 * 164 * @return {{numLogins: number, potentiallyBreachedLogins: number, mobileDeviceConnected: boolean }} 165 */ 166 async getLoginData() { 167 if (gTestOverride && "getLoginData" in gTestOverride) { 168 return gTestOverride.getLoginData(); 169 } 170 171 try { 172 if (await lazy.fxAccounts.getSignedInUser()) { 173 await lazy.fxAccounts.device.refreshDeviceList(); 174 } 175 } catch (e) { 176 console.error("There was an error fetching login data: ", e.message); 177 } 178 179 const userFacingLogins = 180 Services.logins.countLogins("", "", "") - 181 Services.logins.countLogins( 182 lazy.FXA_PWDMGR_HOST, 183 null, 184 lazy.FXA_PWDMGR_REALM 185 ); 186 187 let potentiallyBreachedLogins = null; 188 // Get the stats for number of potentially breached Lockwise passwords 189 // if the Primary Password isn't locked. 190 if (userFacingLogins && Services.logins.isLoggedIn) { 191 const logins = await lazy.LoginHelper.getAllUserFacingLogins(); 192 potentiallyBreachedLogins = 193 await lazy.LoginBreaches.getPotentialBreachesByLoginGUID(logins); 194 } 195 196 let mobileDeviceConnected = 197 lazy.fxAccounts.device.recentDeviceList && 198 lazy.fxAccounts.device.recentDeviceList.filter( 199 device => device.type == "mobile" 200 ).length; 201 202 return { 203 numLogins: userFacingLogins, 204 potentiallyBreachedLogins: potentiallyBreachedLogins 205 ? potentiallyBreachedLogins.size 206 : 0, 207 mobileDeviceConnected, 208 }; 209 } 210 211 /** 212 * @typedef {object} ProtectionsMonitorData 213 * @property {number} monitoredEmails 214 * @property {number} numBreaches 215 * @property {number} passwords 216 * @property {?string} userEmail 217 * @property {boolean} error 218 */ 219 220 /** 221 * Retrieves monitor data for the user. 222 * 223 * @return {ProtectionsMonitorData} 224 */ 225 async getMonitorData() { 226 if (gTestOverride && "getMonitorData" in gTestOverride) { 227 monitorResponse = gTestOverride.getMonitorData(); 228 monitorResponse.timestamp = Date.now(); 229 // In a test, expect this to not fetch from the monitor endpoint due to the timestamp guaranteeing we use the cache. 230 monitorResponse = await this.fetchUserBreachStats(); 231 return monitorResponse; 232 } 233 234 let monitorData = {}; 235 let userEmail = null; 236 let token = await this.getMonitorScopedOAuthToken(); 237 238 try { 239 if (token) { 240 monitorData = await this.fetchUserBreachStats(token); 241 242 // Send back user's email so the protections report can direct them to the proper 243 // OAuth flow on Monitor. 244 const { email } = await lazy.fxAccounts.getSignedInUser(); 245 userEmail = email; 246 } else { 247 // If no account exists, then the user is not logged in with an fxAccount. 248 monitorData = { 249 errorMessage: "No account", 250 }; 251 } 252 } catch (e) { 253 console.error(e.message); 254 monitorData.errorMessage = e.message; 255 256 // If the user's OAuth token is invalid, we clear the cached token and refetch 257 // again. If OAuth token is invalid after the second fetch, then the monitor UI 258 // will simply show the "no logins" UI version. 259 if (e.message === INVALID_OAUTH_TOKEN) { 260 await lazy.fxAccounts.removeCachedOAuthToken({ token }); 261 token = await this.getMonitorScopedOAuthToken(); 262 263 try { 264 monitorData = await this.fetchUserBreachStats(token); 265 } catch (_) { 266 console.error(e.message); 267 } 268 } else if (e.message === USER_UNSUBSCRIBED_TO_MONITOR) { 269 // Send back user's email so the protections report can direct them to the proper 270 // OAuth flow on Monitor. 271 const { email } = await lazy.fxAccounts.getSignedInUser(); 272 userEmail = email; 273 } else { 274 monitorData.errorMessage = e.message || "An error ocurred."; 275 } 276 } 277 278 return { 279 ...monitorData, 280 userEmail, 281 error: !!monitorData.errorMessage, 282 }; 283 } 284 285 async getMonitorScopedOAuthToken() { 286 let token = null; 287 288 try { 289 token = await lazy.fxAccounts.getOAuthToken({ scope: SCOPE_MONITOR }); 290 } catch (e) { 291 console.error( 292 "There was an error fetching the user's token: ", 293 e.message 294 ); 295 } 296 297 return token; 298 } 299 300 /** 301 * The proxy card will only show if the user is in the US, has the browser language in "en-US", 302 * and does not yet have Proxy installed. 303 */ 304 async shouldShowProxyCard() { 305 const region = lazy.Region.home || ""; 306 const languages = Services.locale.acceptLanguages; 307 const alreadyInstalled = await lazy.AddonManager.getAddonByID( 308 SECURE_PROXY_ADDON_ID 309 ); 310 311 return ( 312 region.toLowerCase() === "us" && 313 !alreadyInstalled && 314 languages.toLowerCase().includes("en-us") 315 ); 316 } 317 318 async VPNSubStatus() { 319 // For testing, set vpn sub status manually 320 if (gTestOverride && "vpnOverrides" in gTestOverride) { 321 return gTestOverride.vpnOverrides(); 322 } 323 324 let vpnToken; 325 try { 326 vpnToken = await lazy.fxAccounts.getOAuthToken({ scope: SCOPE_VPN }); 327 } catch (e) { 328 console.error( 329 "There was an error fetching the user's token: ", 330 e.message 331 ); 332 // there was an error, assume user is not subscribed to VPN 333 return false; 334 } 335 let headers = new Headers(); 336 headers.append("Authorization", `Bearer ${vpnToken}`); 337 const request = new Request(VPN_ENDPOINT, { headers }); 338 const res = await fetch(request); 339 if (res.ok) { 340 const result = await res.json(); 341 for (let sub of result) { 342 if (sub.subscriptionId == VPN_SUB_ID) { 343 return true; 344 } 345 } 346 return false; 347 } 348 // unknown logic: assume user is not subscribed to VPN 349 return false; 350 } 351 352 async receiveMessage(aMessage) { 353 let win = this.browsingContext.top.embedderElement.ownerGlobal; 354 switch (aMessage.name) { 355 case "OpenAboutLogins": 356 lazy.LoginHelper.openPasswordManager(win, { 357 entryPoint: "Aboutprotections", 358 }); 359 break; 360 case "OpenContentBlockingPreferences": 361 win.openPreferences("privacy-trackingprotection", { 362 origin: "about-protections", 363 }); 364 break; 365 case "OpenSyncPreferences": 366 win.openTrustedLinkIn("about:preferences#sync", "tab"); 367 break; 368 case "FetchContentBlockingEvents": { 369 let dataToSend = {}; 370 let displayNames = new Services.intl.DisplayNames(undefined, { 371 type: "weekday", 372 style: "abbreviated", 373 calendar: "gregory", 374 }); 375 376 // Weekdays starting Sunday (7) to Saturday (6). 377 let weekdays = [7, 1, 2, 3, 4, 5, 6].map(day => displayNames.of(day)); 378 dataToSend.weekdays = weekdays; 379 380 if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { 381 dataToSend.isPrivate = true; 382 return dataToSend; 383 } 384 let sumEvents = await lazy.TrackingDBService.sumAllEvents(); 385 let earliestDate = 386 await lazy.TrackingDBService.getEarliestRecordedDate(); 387 let eventsByDate = await lazy.TrackingDBService.getEventsByDateRange( 388 aMessage.data.from, 389 aMessage.data.to 390 ); 391 let largest = 0; 392 393 for (let result of eventsByDate) { 394 let count = result.getResultByName("count"); 395 let type = result.getResultByName("type"); 396 let timestamp = result.getResultByName("timestamp"); 397 let typeStr = idToTextMap.get(type); 398 dataToSend[timestamp] = dataToSend[timestamp] ?? { total: 0 }; 399 let currentCnt = dataToSend[timestamp][typeStr] ?? 0; 400 currentCnt += count; 401 dataToSend[timestamp][typeStr] = currentCnt; 402 dataToSend[timestamp].total += count; 403 // Record the largest amount of tracking events found per day, 404 // to create the tallest column on the graph and compare other days to. 405 if (largest < dataToSend[timestamp].total) { 406 largest = dataToSend[timestamp].total; 407 } 408 } 409 dataToSend.largest = largest; 410 dataToSend.earliestDate = earliestDate; 411 dataToSend.sumEvents = sumEvents; 412 413 return dataToSend; 414 } 415 416 case "FetchMonitorData": 417 return this.getMonitorData(); 418 419 case "FetchUserLoginsData": 420 return this.getLoginData(); 421 422 case "ClearMonitorCache": 423 monitorResponse = null; 424 break; 425 426 case "GetShowProxyCard": 427 return await this.shouldShowProxyCard(); 428 429 case "RecordEntryPoint": 430 entrypoint = aMessage.data.entrypoint; 431 break; 432 433 case "FetchEntryPoint": 434 return entrypoint; 435 436 case "FetchVPNSubStatus": 437 return this.VPNSubStatus(); 438 439 case "FetchShowVPNCard": 440 return lazy.BrowserUtils.shouldShowVPNPromo(); 441 } 442 443 return undefined; 444 } 445 }