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

mozilla / blurts-server / #12632

pending completion
#12632

push

circleci

web-flow
Merge pull request #2854 from mozilla/MNTOR-741

MNTOR-741

282 of 1416 branches covered (19.92%)

Branch coverage included in aggregate %.

107 of 107 new or added lines in 9 files covered. (100.0%)

959 of 3912 relevant lines covered (24.51%)

2.04 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
 *
74
 * @param {Array} dataClasses
75
 * @returns Array sanitized data classes array
76
 */
77
function formatDataClassesArray (dataClasses) {
78
  return dataClasses.map(dataClass =>
×
79
    dataClass.toLowerCase()
×
80
      .replace(/[^-a-z0-9]/g, '-')
81
      .replace(/-{2,}/g, '-')
82
      .replace(/(^-|-$)/g, '')
83
  )
84
}
85

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

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

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

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

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

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

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

166
  if (breachedEmail) {
×
167
    return {
×
168
      breachedEmail,
169
      recipientEmail: allEmailsToPrimary ? primaryEmail : breachedEmail,
×
170
      signupLanguage
171
    }
172
  }
173

174
  return {
×
175
    breachedEmail: primaryEmail,
176
    recipientEmail: primaryEmail,
177
    signupLanguage
178
  }
179
}
180

181
/**
182
 * Filter breaches that we would not like to show.
183
 *
184
 * @param {Array} breaches
185
 * @returns {Array} filteredBreaches
186
 */
187
function getFilteredBreaches (breaches) {
188
  return breaches.filter(breach => (
×
189
    !breach.IsRetired &&
×
190
    !breach.IsSpamList &&
191
    !breach.IsFabricated &&
192
    breach.IsVerified &&
193
    breach.Domain !== ''
194
  ))
195
}
196

197
/**
198
A range of hashes can be searched by passing the hash prefix in a GET request:
199
GET /breachedaccount/range/[hash prefix]
200

201
 * @param {string} sha1 first 6 chars of email sha1
202
 * @param {Array} allBreaches
203
 * @param {boolean} includeSensitive
204
 * @param {boolean} filterBreaches
205
 * @returns
206
 */
207
async function getBreachesForEmail (sha1, allBreaches, includeSensitive = false, filterBreaches = true) {
×
208
  let foundBreaches = []
×
209
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
210
  const path = `/breachedaccount/range/${sha1Prefix}`
×
211

212
  const response = await kAnonReq(path)
×
213
  if (!response) {
×
214
    return []
×
215
  }
216
  // Parse response body, format:
217
  // [
218
  //   {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
219
  //   {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
220
  // ]
221
  for (const breachedAccount of response) {
×
222
    if (sha1.toUpperCase() === sha1Prefix + breachedAccount.hashSuffix) {
×
223
      foundBreaches = allBreaches.filter(breach => breachedAccount.websites.includes(breach.Name))
×
224
      if (filterBreaches) {
×
225
        foundBreaches = getFilteredBreaches(foundBreaches)
×
226
      }
227

228
      // NOTE: DO NOT CHANGE THIS SORT LOGIC
229
      // We store breach resolutions by recency indices,
230
      // so that our DB does not contain any part of any user's list of accounts
231
      foundBreaches.sort((a, b) => {
×
232
        return new Date(b.AddedDate) - new Date(a.AddedDate)
×
233
      })
234

235
      break
×
236
    }
237
  }
238

239
  if (includeSensitive) {
×
240
    return foundBreaches
×
241
  }
242
  return foundBreaches.filter(
×
243
    breach => !breach.IsSensitive
×
244
  )
245
}
246

247
function getBreachByName (allBreaches, breachName) {
248
  breachName = breachName.toLowerCase()
×
249
  if (RENAMED_BREACHES.includes(breachName)) {
×
250
    breachName = RENAMED_BREACHES_MAP[breachName]
×
251
  }
252
  const foundBreach = allBreaches.find(breach => breach.Name.toLowerCase() === breachName)
×
253
  return foundBreach
×
254
}
255

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

277
  return await kAnonReq(path, options)
×
278
}
279

280
/**
281
 * A range subscription can be deleted with the following request:
282
 * DELETE /range/[hash prefix]
283

284
 * There is one possible response code that can be returned:
285
 * HTTP 200: Range subscription successfully deleted
286

287
 * @param {string} sha1 sha1 of the email being subscribed
288
 * @returns 200 response codes
289
 */
290
async function deleteSubscribedHash (sha1) {
291
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
292
  const path = `/range/${sha1Prefix}`
×
293
  const options = {
×
294
    Method: 'DELETE'
295
  }
296

297
  return await kAnonReq(path, options)
×
298
}
299

300
export {
301
  req,
302
  kAnonReq,
303
  formatDataClassesArray,
304
  loadBreachesIntoApp,
305
  getAddressesAndLanguageForEmail,
306
  getBreachesForEmail,
307
  getBreachByName,
308
  getAllBreachesFromDb,
309
  getFilteredBreaches,
310
  subscribeHash,
311
  deleteSubscribedHash
312
}
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