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

mozilla / blurts-server / #12617

pending completion
#12617

push

circleci

web-flow
Merge pull request #2875 from mozilla/MNTOR-1269/fix-redis-locale-store-bug

fix redis overwriting `AsyncLocalStore`

282 of 1398 branches covered (20.17%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 2 files covered. (100.0%)

959 of 3805 relevant lines covered (25.2%)

2.1 hits per line

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

0.0
/src/app.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 crypto from 'node:crypto'
6

7
import express from 'express'
8
import session from 'express-session'
9
import helmet from 'helmet'
10
import accepts from 'accepts'
11
import { createClient } from 'redis'
12
import RedisStore from 'connect-redis'
13
import cookieParser from 'cookie-parser'
14
import rateLimit from 'express-rate-limit'
15
import Sentry from '@sentry/node'
16
import '@sentry/tracing'
17

18
import AppConstants from './app-constants.js'
19
import { localStorage } from './utils/local-storage.js'
20
import { errorHandler } from './middleware/error.js'
21
import { doubleCsrfProtection } from './utils/csrf.js'
22
import { initFluentBundles, updateLocale, getMessageWithLocale, getMessage } from './utils/fluent.js'
23
import { loadBreachesIntoApp } from './utils/hibp.js'
24
import { RateLimitError } from './utils/error.js'
25
import { initEmail } from './utils/email.js'
26
import indexRouter from './routes/index.js'
27

28
const app = express()
×
29
const isDev = AppConstants.NODE_ENV === 'dev'
×
30

31
// init sentry
32
Sentry.init({
×
33
  dsn: AppConstants.SENTRY_DSN,
34
  environment: AppConstants.NODE_ENV,
35
  debug: isDev,
36
  beforeSend (event, hint) {
37
    if (!hint.originalException.locales || hint.originalException.locales[0] === 'en') return event // return if no localization or localization is in english
×
38

39
    // try to force an english translation for the error message if localized
40
    if (hint.originalException.fluentID) {
×
41
      event.exception.values[0].value = getMessageWithLocale(hint.originalException.fluentID, 'en') || getMessage(hint.originalException.fluentID)
×
42
    }
43

44
    return event
×
45
  }
46
})
47

48
// Determine from where to serve client code/assets:
49
// Build script is triggered for `npm start` and assets are served from /dist.
50
// Build script is NOT run for `npm run dev`, assets are served from /src, and nodemon restarts server without build (faster dev).
51
const staticPath =
52
  process.env.npm_lifecycle_event === 'start' ? '../dist' : './client'
×
53

54
await initFluentBundles()
×
55

56
async function getRedisStore () {
57
  if (['', 'redis-mock'].includes(AppConstants.REDIS_URL)) {
×
58
    // allow mock redis for setups without local redis server
59
    const { redisMockClient } = await import('./utils/redis-mock.js')
×
60
    return new RedisStore({ client: redisMockClient })
×
61
  }
62

63
  const redisClient = createClient({ url: AppConstants.REDIS_URL })
×
64
  await redisClient.connect().catch(console.error)
×
65
  return new RedisStore({ client: redisClient })
×
66
}
67

68
// middleware
69
app.use(
×
70
  helmet({
71
    crossOriginEmbedderPolicy: false
72
  })
73
)
74

75
app.use(
×
76
  Sentry.Handlers.requestHandler({
77
    request: ['headers', 'method', 'url'], // omit cookies, data, query_string
78
    user: ['id'] // omit username, email
79
  })
80
)
81

82
const imgSrc = [
×
83
  "'self'"
84
]
85

86
if (AppConstants.FXA_ENABLED) {
×
87
  const fxaSrc = new URL(AppConstants.OAUTH_PROFILE_URI).origin
×
88
  imgSrc.push(fxaSrc)
×
89
}
90

91
// Support GA4 per https://developers.google.com/tag-platform/tag-manager/web/csp
92
imgSrc.push('www.googletagmanager.com')
×
93

94
// disable forced https to allow localhost on Safari
95
app.use((_req, res, _next) => {
×
96
  res.locals.nonce = crypto.randomBytes(16).toString('hex')
×
97
  helmet.contentSecurityPolicy({
×
98
    directives: {
99
      upgradeInsecureRequests: isDev ? null : [],
×
100
      scriptSrc: [
101
        "'self'",
102
        // Support GA4 per https://developers.google.com/tag-platform/tag-manager/web/csp
103
        `'nonce-${res.locals.nonce}'`
104
      ],
105
      imgSrc,
106
      connectSrc: [
107
        "'self'",
108
        // Support GA4 per https://developers.google.com/tag-platform/tag-manager/web/csp
109
        'https://*.google-analytics.com',
110
        'https://*.analytics.google.com',
111
        'https://*.googletagmanager.com'
112
      ]
113
    }
114
  })(_req, res, _next)
115
})
116

117
// fallback to default 'no-referrer' only when 'strict-origin-when-cross-origin' not available
118
app.use(
×
119
  helmet.referrerPolicy({
120
    policy: ['no-referrer', 'strict-origin-when-cross-origin']
121
  })
122
)
123

124
// When a text/html request is received, negotiate and store the requested language
125
// Using asyncLocalStorage avoids having to pass req context down through every function (e.g. getMessage())
126
app.use((req, res, next) => {
×
127
  if (!req.headers.accept?.startsWith('text/html')) return next()
×
128

129
  localStorage.run(new Map(), () => {
×
130
    req.locale = updateLocale(accepts(req).languages())
×
131
    localStorage.getStore().set('locale', req.locale)
×
132
    next()
×
133
  })
134
})
135

136
// MNTOR-1009, 1117:
137
// Because of proxy settings, request / cookies are not persisted between calls
138
// Setting the trust proxy to high and securing the cookie allowed the cookie to persist
139
// If cookie.secure is set as true, for nodejs behind proxy, "trust proxy" needs to be set
140
app.set('trust proxy', 1)
×
141

142
// session
143
const SESSION_DURATION_HOURS = AppConstants.SESSION_DURATION_HOURS || 48
×
144
app.use(
×
145
  session({
146
    cookie: {
147
      maxAge: SESSION_DURATION_HOURS * 60 * 60 * 1000, // 48 hours
148
      rolling: true,
149
      sameSite: 'lax',
150
      secure: !isDev
151
    },
152
    resave: false,
153
    saveUninitialized: true,
154
    secret: AppConstants.COOKIE_SECRET,
155
    store: await getRedisStore()
156
  })
157
)
158

159
// Load breaches into namespaced cache
160
try {
×
161
  await loadBreachesIntoApp(app)
×
162
} catch (error) {
163
  console.error('Error loading breaches into app.locals', error)
×
164
}
165

166
app.use(express.static(staticPath))
×
167
app.use(express.json())
×
168
app.use(cookieParser(AppConstants.COOKIE_SECRET))
×
169
app.use(doubleCsrfProtection)
×
170

171
const apiLimiter = rateLimit({
×
172
  windowMs: 15 * 60 * 1000, // 15 minutes
173
  max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
174
  standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
175
  legacyHeaders: false // Disable the `X-RateLimit-*` headers
176
})
177

178
app.use('/api', apiLimiter)
×
179

180
// routing
181
app.use('/', indexRouter)
×
182

183
// sentry error handler
184
app.use(Sentry.Handlers.errorHandler({
×
185
  shouldHandleError (error) {
186
    if (error instanceof RateLimitError) return true
×
187
  }
188
}))
189

190
// app error handler
191
app.use(errorHandler)
×
192

193
app.listen(AppConstants.PORT, async function () {
×
194
  console.info(`MONITOR V2: Server listening at ${this.address().port}`)
×
195
  console.info(`Static files served from ${staticPath}`)
×
196
  try {
×
197
    await initEmail()
×
198
    console.info('Email initialized')
×
199
  } catch (ex) {
200
    console.error('try-initialize-email-error', { ex })
×
201
  }
202
})
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