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

mozilla / blurts-server / 17fce790-ebdd-469f-a243-e5224a22b5b4

pending completion
17fce790-ebdd-469f-a243-e5224a22b5b4

Pull #2790

circleci

Florian Zia
merge: main -> MNTOR-1056-Migrate-breach-alert-email
Pull Request #2790: MNTOR-1056: Migrate breach alert email

282 of 1293 branches covered (21.81%)

Branch coverage included in aggregate %.

58 of 58 new or added lines in 4 files covered. (100.0%)

959 of 3510 relevant lines covered (27.32%)

4.44 hits per line

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

0.0
/src/controllers/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 { acceptedLanguages, negotiateLanguages } from 'fluent-langneg'
6
import AppConstants from '../app-constants.js'
7

8
import { getSubscribersByHashes } from '../db/tables/subscribers.js'
9
import { getEmailAddressesByHashes } from '../db/tables/email_addresses.js'
10
import { getEmailTemplate } from '../emails/templates/email-2022.js'
11
import { breachAlertEmailPartial } from '../emails/partials/email-breach-alert.js'
12

13
import {
14
  getEmailCtaHref,
15
  getUnsubscribeUrl,
16
  sendEmail
17
} from '../utils/email.js'
18
import { getMessage } from '../utils/fluent.js'
19
import {
20
  getAddressesAndLanguageForEmail,
21
  getBreachByName,
22
  loadBreachesIntoApp
23
} from '../utils/hibp.js'
24
import mozlog from '../utils/log.js'
25

26
const log = mozlog('controllers.hibp')
×
27

28
/**
29
 * Whenever a breach is detected on the HIBP side, HIBP sends a request to this endpoint.
30
 * This function attempts to retrieve the breach info from the local cache, if not found
31
 * it retrieves it from the database
32
 * A breach notification contains the following parameters:
33
 * - breachName
34
 * - hashPrefix
35
 * - hashSuffixes
36
 * More about how account identities are anonymized: https://blog.mozilla.org/security/2018/06/25/scanning-breached-accounts-k-anonymity/
37
 */
38
async function notify (req, res) {
39
  if (!req.token || req.token !== AppConstants.HIBP_NOTIFY_TOKEN) {
×
40
    const errorMessage = 'HIBP notify endpoint requires valid authorization token.'
×
41
    throw new Error(errorMessage)
×
42
  }
43
  if (!['breachName', 'hashPrefix', 'hashSuffixes'].every(req.body?.hasOwnProperty, req.body)) {
×
44
    throw new Error('HIBP breach notification: requires breachName, hashPrefix, and hashSuffixes.')
×
45
  }
46

47
  const { breachName, hashPrefix, hashSuffixes } = req.body
×
48

49
  let breachAlert = getBreachByName(req.app.locals.breaches, breachName)
×
50

51
  if (!breachAlert) {
×
52
    // If breach isn't found, try to reload breaches from HIBP
53
    log.debug('notify', 'Breach is not found, reloading breaches...')
×
54
    await loadBreachesIntoApp(req.app)
×
55
    breachAlert = getBreachByName(req.app.locals.breaches, breachName)
×
56
    if (!breachAlert) {
×
57
      // If breach *still* isn't found, we have a real error
58
      throw new Error('Unrecognized breach: ' + breachName)
×
59
    }
60
  }
61

62
  const { IsVerified, Domain, IsFabricated, IsSpamList } = breachAlert
×
63

64
  // If any of the following conditions are not satisfied:
65
  // Do not send the breach alert email! The `logId`s are being used for
66
  // logging in case we decide to not send the alert.
67
  const emailDeliveryConditions = [
×
68
    {
69
      logId: 'isNotVerified',
70
      condition: !IsVerified
71
    },
72
    {
73
      logId: 'domainEmpty',
74
      condition: Domain === ''
75
    },
76
    {
77
      logId: 'isFabricated',
78
      condition: IsFabricated
79
    },
80
    {
81
      logId: 'isSpam',
82
      condition: IsSpamList
83
    }
84
  ]
85

86
  const unsatisfiedConditions = emailDeliveryConditions.filter(condition => (
×
87
    condition.condition
×
88
  ))
89

90
  const doNotSendEmail = unsatisfiedConditions?.length > 0
×
91

92
  if (doNotSendEmail) {
×
93
    // Get a list of the failed condition `logId`s
94
    const conditionLogIds = unsatisfiedConditions
×
95
      .map(condition => condition.logId)
×
96
      .join(', ')
97

98
    log.info('Breach alert email was not sent.', {
×
99
      name: breachAlert.Name,
100
      reason: `The following conditions were not satisfied: ${conditionLogIds}.`
101
    })
102

103
    return res.status(200).json({
×
104
      info: 'Breach loaded into database. Subscribers not notified.'
105
    })
106
  }
107

108
  try {
×
109
    const reqHashPrefix = hashPrefix.toLowerCase()
×
110
    const hashes = hashSuffixes.map(suffix => reqHashPrefix + suffix.toLowerCase())
×
111
    const subscribers = await getSubscribersByHashes(hashes)
×
112
    const emailAddresses = await getEmailAddressesByHashes(hashes)
×
113
    const recipients = subscribers.concat(emailAddresses)
×
114

115
    log.info('notification', {
×
116
      breachAlertName: breachAlert.Name,
117
      length: recipients.length
118
    })
119

120
    const utmCampaignId = 'breach-alert'
×
121
    const notifiedRecipients = []
×
122

123
    for (const recipient of recipients) {
×
124
      log.info('notify', { recipient })
×
125

126
      // Get subscriber ID from:
127
      // - `subscriber_id`: if `email_addresses` record
128
      // - `id`: if `subscribers` record
129
      const subscriberId = recipient.subscriber_id || recipient.id
×
130
      const {
131
        recipientEmail,
132
        breachedEmail,
133
        signupLanguage
134
      } = getAddressesAndLanguageForEmail(recipient)
×
135

136
      const requestedLanguage = signupLanguage
×
137
        ? acceptedLanguages(signupLanguage)
138
        : ''
139

140
      const availableLanguages = req.app.locals.AVAILABLE_LANGUAGES
×
141
      const supportedLocales = negotiateLanguages(
×
142
        requestedLanguage,
143
        availableLanguages,
144
        { defaultLocale: 'en' }
145
      )
146

147
      if (!notifiedRecipients.includes(breachedEmail)) {
×
148
        const data = {
×
149
          breachAlert,
150
          ctaHref: getEmailCtaHref(utmCampaignId, 'dashboard-cta'),
151
          heading: getMessage('email-spotted-new-breach'),
152
          partial: {
153
            name: 'email-breach-alert'
154
          },
155
          recipientEmail,
156
          subscriberId,
157
          supportedLocales,
158
          unsubscribeUrl: getUnsubscribeUrl(
159
            recipientEmail,
160
            'account-verification-email'
161
          ),
162
          utmCampaign: utmCampaignId
163
        }
164

165
        const emailPartial = breachAlertEmailPartial(data)
×
166
        const emailTemplate = getEmailTemplate(data, emailPartial)
×
167
        const subject = getMessage('breach-alert-subject')
×
168

169
        await sendEmail(recipientEmail, subject, emailTemplate)
×
170

171
        notifiedRecipients.push(breachedEmail)
×
172
      }
173
    }
174

175
    log.info('notified', { length: notifiedRecipients.length })
×
176

177
    res.status(200)
×
178
    res.json({
×
179
      info: 'Notified subscribers of breach.'
180
    })
181
  } catch (error) {
182
    throw new Error(`Notifying subscribers of breach failed: ${error}`)
×
183
  }
184
}
185

186
export { notify }
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