Skip to content

Commit 4fab113

Browse files
authored
Merge pull request #20269 from mozilla/otp-statsd-metrics
feat(auth): add more StatsD metrics for passwordless OTP and account status
2 parents b725a26 + 2b92004 commit 4fab113

4 files changed

Lines changed: 96 additions & 8 deletions

File tree

.claude/skills/fxa-review/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Tell this agent it is a senior security engineer. It should:
5151
- Check CORS configuration — no `*` on credentialed endpoints
5252
- Verify OTP/TOTP handling: constant-time comparison, immediate invalidation, rate limiting
5353
- Check that secrets are accessed via Convict config, not hardcoded or read from env directly
54+
- Check StatsD metric tags for unbounded cardinality: user-controlled values (clientId, email, service) used as metric tags must be validated against a known allowlist (e.g. `getRegisteredClientIds()` or `getClientServiceTags(request)`). Free-form strings as tags allow attackers to blow up Prometheus storage.
5455

5556
Output JSON array with fields: severity, category ("Security"), subcategory, file, line, issue, recommendation.
5657

packages/fxa-auth-server/lib/routes/account.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,6 +1615,8 @@ export class AccountHandler {
16151615

16161616
await this.customs.check(request, email, 'accountStatusCheck');
16171617

1618+
const statusTags = getClientServiceTags(request);
1619+
16181620
// Block creation if email is reserved for secondary email registration
16191621
const normalizedEmail = normalizeEmail(email);
16201622
const existingSecondaryEmailRecord = await getExistingSecondaryEmailRecord(
@@ -1683,6 +1685,13 @@ export class AccountHandler {
16831685
result.invalidDomain = invalidDomain;
16841686
}
16851687

1688+
this.statsd.increment('account.statusCheck.result', {
1689+
exists: String(result.exists),
1690+
passwordlessSupported: String(!!result.passwordlessSupported),
1691+
hasPassword: String(!!result.hasPassword),
1692+
...statusTags,
1693+
});
1694+
16861695
return result;
16871696
} catch (err) {
16881697
if (err.errno === error.ERRNO.ACCOUNT_UNKNOWN) {
@@ -1706,6 +1715,13 @@ export class AccountHandler {
17061715
service
17071716
);
17081717
}
1718+
this.statsd.increment('account.statusCheck.result', {
1719+
exists: 'false',
1720+
passwordlessSupported: String(!!result.passwordlessSupported),
1721+
hasPassword: 'false',
1722+
...statusTags,
1723+
});
1724+
17091725
if (this.customs.v2Enabled()) {
17101726
await this.customs.check(request, email, 'accountStatusCheckFailed');
17111727
}

packages/fxa-auth-server/lib/routes/passwordless.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,12 +1152,17 @@ describe('passwordless statsd metrics', () => {
11521152
payload: {
11531153
email: TEST_EMAIL,
11541154
clientId: 'test-client-id',
1155+
service: 'test-service',
11551156
metricsContext: {
11561157
deviceId: 'device123',
11571158
flowId: 'flow123',
11581159
flowBeginTime: Date.now(),
11591160
},
11601161
},
1162+
app: {
1163+
clientIdTag: 'test-client-id',
1164+
serviceTag: 'test-service',
1165+
},
11611166
});
11621167
});
11631168

@@ -1247,8 +1252,58 @@ describe('passwordless statsd metrics', () => {
12471252
.map((c: any) => c.args[0]);
12481253
expect(incrementCalls).toContain('passwordless.registration.success');
12491254
expect(incrementCalls).toContain('passwordless.confirmCode.success');
1255+
1256+
// confirmCode.success should include isNewAccount tag
1257+
const confirmCall = mockStatsd.increment
1258+
.getCalls()
1259+
.find((c: any) => c.args[0] === 'passwordless.confirmCode.success');
1260+
expect(confirmCall).toBeDefined();
1261+
expect(confirmCall.args[1]).toEqual(
1262+
expect.objectContaining({ isNewAccount: 'true' })
1263+
);
12501264
});
12511265
});
1266+
1267+
it('should increment passwordless.blocked when client is not allowed', () => {
1268+
mockDB.accountRecord = sinon.spy(() =>
1269+
Promise.reject(error.unknownAccount())
1270+
);
1271+
1272+
routes = makeRoutes({
1273+
log: mockLog,
1274+
db: mockDB,
1275+
customs: mockCustoms,
1276+
statsd: mockStatsd,
1277+
config: {
1278+
passwordlessOtp: {
1279+
enabled: true,
1280+
ttl: 300,
1281+
digits: 6,
1282+
allowedClientServices: {},
1283+
},
1284+
},
1285+
});
1286+
route = getRoute(routes, '/account/passwordless/send_code', 'POST');
1287+
1288+
return runTest(route, mockRequest).then(
1289+
() => {
1290+
throw new Error('should have failed');
1291+
},
1292+
() => {
1293+
const blockedCall = mockStatsd.increment
1294+
.getCalls()
1295+
.find((c: any) => c.args[0] === 'passwordless.blocked');
1296+
expect(blockedCall).toBeDefined();
1297+
expect(blockedCall.args[1]).toEqual(
1298+
expect.objectContaining({
1299+
reason: 'clientNotAllowed',
1300+
clientId: 'test-client-id',
1301+
service: 'test-service',
1302+
})
1303+
);
1304+
}
1305+
);
1306+
});
12521307
});
12531308

12541309
describe('/account/passwordless/resend_code', () => {

packages/fxa-auth-server/lib/routes/passwordless.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ class PasswordlessHandler {
119119
email: string,
120120
clientId: string | undefined,
121121
service: string | undefined,
122-
logTag: string
122+
logTag: string,
123+
request: AuthRequest
123124
) {
124125
const hasLinkedAccount =
125126
account && (account.linkedAccounts?.length || 0) > 0;
@@ -137,6 +138,10 @@ class PasswordlessHandler {
137138
clientId,
138139
service,
139140
});
141+
this.statsd.increment('passwordless.blocked', {
142+
reason: 'clientNotAllowed',
143+
...getClientServiceTags(request),
144+
});
140145
throw error.featureNotEnabled();
141146
}
142147
}
@@ -147,6 +152,10 @@ class PasswordlessHandler {
147152
this.config.passwordlessOtp.enabled
148153
)
149154
) {
155+
this.statsd.increment('passwordless.blocked', {
156+
reason: 'notEligible',
157+
...getClientServiceTags(request),
158+
});
150159
throw error.cannotCreatePassword();
151160
}
152161
}
@@ -168,7 +177,8 @@ class PasswordlessHandler {
168177
email,
169178
clientId,
170179
service,
171-
'sendCode'
180+
'sendCode',
181+
request
172182
);
173183

174184
return this.generateAndSendOtp(request, email, account, isNewAccount);
@@ -197,7 +207,8 @@ class PasswordlessHandler {
197207
email,
198208
clientId,
199209
service,
200-
'confirmCode'
210+
'confirmCode',
211+
request
201212
);
202213

203214
// Verify OTP
@@ -229,6 +240,10 @@ class PasswordlessHandler {
229240
this.log.info('passwordless.confirmCode.totpRequired', {
230241
uid: account.uid,
231242
});
243+
this.statsd.increment(
244+
'passwordless.confirmCode.totpRequired',
245+
getClientServiceTags(request)
246+
);
232247
}
233248
}
234249

@@ -267,10 +282,10 @@ class PasswordlessHandler {
267282
tokenVerificationId
268283
);
269284

270-
this.statsd.increment(
271-
'passwordless.confirmCode.success',
272-
getClientServiceTags(request)
273-
);
285+
this.statsd.increment('passwordless.confirmCode.success', {
286+
...getClientServiceTags(request),
287+
isNewAccount: String(isNewAccount),
288+
});
274289

275290
if (!isNewAccount) {
276291
this.glean.login.complete(request, {
@@ -326,7 +341,8 @@ class PasswordlessHandler {
326341
email,
327342
clientId,
328343
service,
329-
'resendCode'
344+
'resendCode',
345+
request
330346
);
331347

332348
// Delete existing code before sending a new one

0 commit comments

Comments
 (0)