• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / blurts-server / #12299

pending completion
#12299

push

circleci

web-flow
Merge pull request #2790 from mozilla/MNTOR-1056-Migrate-breach-alert-email

Adds email preview for email verification and breach notification

282 of 1375 branches covered (20.51%)

Branch coverage included in aggregate %.

174 of 174 new or added lines in 17 files covered. (100.0%)

959 of 3709 relevant lines covered (25.86%)

2.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/utils/hibp.js
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 mozlog from './log.js'
6
import AppConstants from '../app-constants.js'
7
import { fluentError } from './fluent.js'
8
import { getAllBreaches, upsertBreaches } from '../db/tables/breaches.js'
9
const { HIBP_THROTTLE_MAX_TRIES, HIBP_THROTTLE_DELAY, HIBP_API_ROOT, HIBP_KANON_API_ROOT, HIBP_KANON_API_TOKEN } = AppConstants
×
10

11
// TODO: fix hardcode
12
const HIBP_USER_AGENT = 'monitor/1.0.0'
×
13
// When HIBP "re-names" a breach, it keeps its old 'Name' value but gets a new 'Title'
14
// We use 'Name' in Firefox (via Remote Settings), so we have to maintain our own mapping of re-named breaches.
15
const RENAMED_BREACHES = ['covve']
×
16
const RENAMED_BREACHES_MAP = {
×
17
  covve: 'db8151dd'
18
}
19
const log = mozlog('hibp')
×
20

21
function _addStandardOptions (options = {}) {
×
22
  const hibpOptions = {
×
23
    headers: {
24
      'User-Agent': HIBP_USER_AGENT
25
    }
26
  }
27
  return Object.assign(options, hibpOptions)
×
28
}
29

30
async function _throttledFetch (url, reqOptions, tryCount = 1) {
×
31
  try {
×
32
    const response = await fetch(url, reqOptions)
×
33
    if (response.ok) return await response.json()
×
34

35
    switch (response.status) {
×
36
      case 404:
37
        // 404 can mean "no results", return undefined response
38
        return undefined
×
39
      case 429:
40
        log.info('_throttledFetch', { err: 'Error 429, tryCount: ' + tryCount })
×
41
        if (tryCount >= HIBP_THROTTLE_MAX_TRIES) {
×
42
          throw fluentError('error-hibp-throttled')
×
43
        } else {
44
          tryCount++
×
45
          await new Promise(resolve => setTimeout(resolve, HIBP_THROTTLE_DELAY * tryCount))
×
46
          return await _throttledFetch(url, reqOptions, tryCount)
×
47
        }
48
      default:
49
        throw new Error(`bad response: ${response.status}`)
×
50
    }
51
  } catch (err) {
52
    log.error('_throttledFetch', { err })
×
53
    throw fluentError('error-hibp-connect')
×
54
  }
55
}
56

57
async function req (path, options = {}) {
×
58
  const url = `${HIBP_API_ROOT}${path}`
×
59
  const reqOptions = _addStandardOptions(options)
×
60
  return await _throttledFetch(url, reqOptions)
×
61
}
62

63
async function kAnonReq (path, options = {}) {
×
64
  // Construct HIBP url and standard headers
65
  const url = `${HIBP_KANON_API_ROOT}${path}?code=${encodeURIComponent(HIBP_KANON_API_TOKEN)}`
×
66
  const reqOptions = _addStandardOptions(options)
×
67
  return await _throttledFetch(url, reqOptions)
×
68
}
69

70
/**
71
 * Sanitize data classes
72
 * ie. "Email Addresses" -> "email-addresses"
73
 * @param {Array} dataClasses
74
 * @returns Array sanitized data classes array
75
 */
76
function formatDataClassesArray (dataClasses) {
77
  return dataClasses.map(dataClass =>
×
78
    dataClass.toLowerCase()
×
79
      .replace(/[^-a-z0-9]/g, '-')
80
      .replace(/-{2,}/g, '-')
81
      .replace(/(^-|-$)/g, '')
82
  )
83
}
84

85
/**
86
 * Get all breaches from the database table "breaches",
87
 * sanitize it, and return a javascript array
88
 * @returns formatted all breaches array
89
 */
90
async function getAllBreachesFromDb () {
91
  let dbBreaches = []
×
92
  try {
×
93
    dbBreaches = await getAllBreaches()
×
94
  } catch (e) {
95
    log.error('getAllBreachesFromDb', 'No breaches exist in the database: ' + e)
×
96
    return dbBreaches
×
97
  }
98

99
  // TODO: we can do some filtering here for the most commonly used fields
100
  // TODO: change field names to camel case
101
  return dbBreaches.map(breach => ({
×
102
    Id: breach.id,
103
    Name: breach.name,
104
    Title: breach.title,
105
    Domain: breach.domain,
106
    BreachDate: breach.breach_date,
107
    AddedDate: breach.added_date,
108
    ModifiedDate: breach.modified_date,
109
    PwnCount: breach.pwn_count,
110
    Description: breach.description,
111
    LogoPath: breach.logo_path,
112
    DataClasses: breach.data_classes,
113
    IsVerified: breach.is_verified,
114
    IsFabricated: breach.is_fabricated,
115
    IsSensitive: breach.is_sensitive,
116
    IsRetired: breach.is_retired,
117
    IsSpamList: breach.is_spam_list,
118
    IsMalware: breach.is_malware
119
  }))
120
}
121

122
async function loadBreachesIntoApp (app) {
123
  try {
×
124
    // attempt to fetch breaches from the "breaches" database table
125
    const breaches = await getAllBreachesFromDb()
×
126
    log.debug('loadBreachesIntoApp', `loaded breaches from database: ${breaches.length}`)
×
127

128
    // if "breaches" table does not return results, fall back to HIBP request
129
    if (breaches?.length < 1) {
×
130
      const breachesResponse = await req('/breaches')
×
131
      log.debug('loadBreachesIntoApp', `loaded breaches from HIBP: ${breachesResponse.length}`)
×
132

133
      for (const breach of breachesResponse) {
×
134
        breach.DataClasses = formatDataClassesArray(breach.DataClasses)
×
135
        breach.LogoPath = /[^/]*$/.exec(breach.LogoPath)[0]
×
136
        breaches.push(breach)
×
137
      }
138

139
      // sync the "breaches" table with the latest from HIBP
140
      await upsertBreaches(breaches)
×
141
    }
142
    app.locals.breaches = breaches
×
143
    app.locals.breachesLoadedDateTime = Date.now()
×
144
  } catch (error) {
145
    throw fluentError('error-hibp-load-breaches')
×
146
  }
147
  log.info('done-loading-breaches', 'great success 👍')
×
148
}
149

150
/**
151
 * Get addresses and language from either subscribers or email_addresses fields:
152
 * @param {*} recipient
153
 * @returns
154
 */
155
function getAddressesAndLanguageForEmail (recipient) {
156
  const {
157
    all_emails_to_primary: allEmailsToPrimary,
158
    email: breachedEmail,
159
    primary_email: primaryEmail,
160
    signup_language: signupLanguage
161
  } = recipient
×
162

163
  if (breachedEmail) {
×
164
    return {
×
165
      breachedEmail,
166
      recipientEmail: allEmailsToPrimary ? primaryEmail : breachedEmail,
×
167
      signupLanguage
168
    }
169
  }
170

171
  return {
×
172
    breachedEmail: primaryEmail,
173
    recipientEmail: primaryEmail,
174
    signupLanguage
175
  }
176
}
177

178
/**
179
A range of hashes can be searched by passing the hash prefix in a GET request:
180
GET /breachedaccount/range/[hash prefix]
181

182
 * @param {string} sha1 first 6 chars of email sha1
183
 * @param {*} allBreaches
184
 * @param {*} includeSensitive
185
 * @param {*} filterBreaches
186
 * @returns
187
 */
188
async function getBreachesForEmail (sha1, allBreaches, includeSensitive = false, filterBreaches = true) {
×
189
  let foundBreaches = []
×
190
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
191
  const path = `/breachedaccount/range/${sha1Prefix}`
×
192

193
  const response = await kAnonReq(path)
×
194
  if (!response) {
×
195
    return []
×
196
  }
197
  // Parse response body, format:
198
  // [
199
  //   {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
200
  //   {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
201
  // ]
202
  for (const breachedAccount of response) {
×
203
    if (sha1.toUpperCase() === sha1Prefix + breachedAccount.hashSuffix) {
×
204
      foundBreaches = allBreaches.filter(breach => breachedAccount.websites.includes(breach.Name))
×
205
      if (filterBreaches) {
×
206
        foundBreaches = filterBreaches(foundBreaches)
×
207
      }
208

209
      // NOTE: DO NOT CHANGE THIS SORT LOGIC
210
      // We store breach resolutions by recency indices,
211
      // so that our DB does not contain any part of any user's list of accounts
212
      foundBreaches.sort((a, b) => {
×
213
        return new Date(b.AddedDate) - new Date(a.AddedDate)
×
214
      })
215

216
      break
×
217
    }
218
  }
219

220
  if (includeSensitive) {
×
221
    return foundBreaches
×
222
  }
223
  return foundBreaches.filter(
×
224
    breach => !breach.IsSensitive
×
225
  )
226
}
227

228
function getBreachByName (allBreaches, breachName) {
229
  breachName = breachName.toLowerCase()
×
230
  if (RENAMED_BREACHES.includes(breachName)) {
×
231
    breachName = RENAMED_BREACHES_MAP[breachName]
×
232
  }
233
  const foundBreach = allBreaches.find(breach => breach.Name.toLowerCase() === breachName)
×
234
  return foundBreach
×
235
}
236

237
function filterBreaches (breaches) {
238
  return breaches.filter(
×
239
    breach => !breach.IsRetired &&
×
240
                !breach.IsSpamList &&
241
                !breach.IsFabricated &&
242
                breach.IsVerified &&
243
                breach.Domain !== ''
244
  )
245
}
246

247
/**
248
 * A range can be subscribed for callbacks with the following request:
249
 * POST /range/subscribe
250
 * {
251
 *   hashPrefix:"[hash prefix]"
252
 * }
253
 * There are two possible response codes that can be returned:
254
 * 1. HTTP 201: New range subscription has been created
255
 * 2. HTTP 200: Range subscription already exists
256
 * @param {string} sha1 sha1 of the email being subscribed
257
 * @returns 200 or 201 response codes
258
 */
259
async function subscribeHash (sha1) {
260
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
261
  const path = '/range/subscribe'
×
262
  const options = {
×
263
    Method: 'POST',
264
    Body: { hashPrefix: sha1Prefix }
265
  }
266

267
  return await kAnonReq(path, options)
×
268
}
269

270
/**
271
 * A range subscription can be deleted with the following request:
272
 * DELETE /range/[hash prefix]
273

274
 * There is one possible response code that can be returned:
275
 * HTTP 200: Range subscription successfully deleted
276

277
 * @param {string} sha1 sha1 of the email being subscribed
278
 * @returns 200 response codes
279
 */
280
async function deleteSubscribedHash (sha1) {
281
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
282
  const path = `/range/${sha1Prefix}`
×
283
  const options = {
×
284
    Method: 'DELETE'
285
  }
286

287
  return await kAnonReq(path, options)
×
288
}
289

290
export {
291
  req,
292
  kAnonReq,
293
  formatDataClassesArray,
294
  loadBreachesIntoApp,
295
  getAddressesAndLanguageForEmail,
296
  getBreachesForEmail,
297
  getBreachByName,
298
  getAllBreachesFromDb,
299
  filterBreaches,
300
  subscribeHash,
301
  deleteSubscribedHash
302
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc