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

mozilla / blurts-server / #11898

pending completion
#11898

push

circleci

web-flow
Merge pull request #2770 from mozilla/license

Add license headers in source files

282 of 1138 branches covered (24.78%)

Branch coverage included in aggregate %.

959 of 3049 relevant lines covered (31.45%)

2.55 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
    Name: breach.name,
103
    Title: breach.title,
104
    Domain: breach.domain,
105
    BreachDate: breach.breach_date,
106
    AddedDate: breach.added_date,
107
    ModifiedDate: breach.modified_date,
108
    PwnCount: breach.pwn_count,
109
    Description: breach.description,
110
    LogoPath: breach.logo_path,
111
    DataClasses: breach.data_classes,
112
    IsVerified: breach.is_verified,
113
    IsFabricated: breach.is_fabricated,
114
    IsSensitive: breach.is_sensitive,
115
    IsRetired: breach.is_retired,
116
    IsSpamList: breach.is_spam_list,
117
    IsMalware: breach.is_malware
118
  }))
119
}
120

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

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

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

138
      // sync the "breaches" table with the latest from HIBP
139
      await upsertBreaches(breaches)
×
140
    }
141
    app.locals.breaches = breaches
×
142
    app.locals.breachesLoadedDateTime = Date.now()
×
143
  } catch (error) {
144
    throw fluentError('error-hibp-load-breaches')
×
145
  }
146
  log.info('done-loading-breaches', 'great success 👍')
×
147
}
148
/**
149
A range of hashes can be searched by passing the hash prefix in a GET request:
150
GET /breachedaccount/range/[hash prefix]
151

152
 * @param {string} sha1 first 6 chars of email sha1
153
 * @param {*} allBreaches
154
 * @param {*} includeSensitive
155
 * @param {*} filterBreaches
156
 * @returns
157
 */
158
async function getBreachesForEmail (sha1, allBreaches, includeSensitive = false, filterBreaches = true) {
×
159
  let foundBreaches = []
×
160
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
161
  const path = `/breachedaccount/range/${sha1Prefix}`
×
162

163
  const response = await kAnonReq(path)
×
164
  if (!response) {
×
165
    return []
×
166
  }
167
  // Parse response body, format:
168
  // [
169
  //   {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
170
  //   {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
171
  // ]
172
  for (const breachedAccount of response) {
×
173
    if (sha1.toUpperCase() === sha1Prefix + breachedAccount.hashSuffix) {
×
174
      foundBreaches = allBreaches.filter(breach => breachedAccount.websites.includes(breach.Name))
×
175
      if (filterBreaches) {
×
176
        foundBreaches = filterBreaches(foundBreaches)
×
177
      }
178

179
      // NOTE: DO NOT CHANGE THIS SORT LOGIC
180
      // We store breach resolutions by recency indices,
181
      // so that our DB does not contain any part of any user's list of accounts
182
      foundBreaches.sort((a, b) => {
×
183
        return new Date(b.AddedDate) - new Date(a.AddedDate)
×
184
      })
185

186
      break
×
187
    }
188
  }
189

190
  if (includeSensitive) {
×
191
    return foundBreaches
×
192
  }
193
  return foundBreaches.filter(
×
194
    breach => !breach.IsSensitive
×
195
  )
196
}
197

198
function getBreachByName (allBreaches, breachName) {
199
  breachName = breachName.toLowerCase()
×
200
  if (RENAMED_BREACHES.includes(breachName)) {
×
201
    breachName = RENAMED_BREACHES_MAP[breachName]
×
202
  }
203
  const foundBreach = allBreaches.find(breach => breach.Name.toLowerCase() === breachName)
×
204
  return foundBreach
×
205
}
206

207
function filterBreaches (breaches) {
208
  return breaches.filter(
×
209
    breach => !breach.IsRetired &&
×
210
                !breach.IsSpamList &&
211
                !breach.IsFabricated &&
212
                breach.IsVerified &&
213
                breach.Domain !== ''
214
  )
215
}
216

217
/**
218
 * A range can be subscribed for callbacks with the following request:
219
 * POST /range/subscribe
220
 * {
221
 *   hashPrefix:"[hash prefix]"
222
 * }
223
 * There are two possible response codes that can be returned:
224
 * 1. HTTP 201: New range subscription has been created
225
 * 2. HTTP 200: Range subscription already exists
226
 * @param {string} sha1 sha1 of the email being subscribed
227
 * @returns 200 or 201 response codes
228
 */
229
async function subscribeHash (sha1) {
230
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
231
  const path = '/range/subscribe'
×
232
  const options = {
×
233
    method: 'POST',
234
    json: { hashPrefix: sha1Prefix }
235
  }
236

237
  return await kAnonReq(path, options)
×
238
}
239

240
/**
241
 * A range subscription can be deleted with the following request:
242
 * DELETE /range/[hash prefix]
243

244
 * There is one possible response code that can be returned:
245
 * HTTP 200: Range subscription successfully deleted
246

247
 * @param {string} sha1 sha1 of the email being subscribed
248
 * @returns 200 response codes
249
 */
250
async function deleteSubscribedHash (sha1) {
251
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
252
  const path = `/range${sha1Prefix}`
×
253
  const options = {
×
254
    method: 'DELETE'
255
  }
256

257
  return await kAnonReq(path, options)
×
258
}
259

260
export {
261
  req,
262
  kAnonReq,
263
  formatDataClassesArray,
264
  loadBreachesIntoApp,
265
  getBreachesForEmail,
266
  getBreachByName,
267
  getAllBreachesFromDb,
268
  filterBreaches,
269
  subscribeHash,
270
  deleteSubscribedHash
271
}
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