Skip to content

Commit e58b1d1

Browse files
authored
Merge pull request #18356 from mozilla/FXA-11065
task(recovery-phone): Add check for sms pumping risk
2 parents 3e3fe88 + ea5c554 commit e58b1d1

8 files changed

Lines changed: 90 additions & 4 deletions

File tree

libs/accounts/recovery-phone/src/lib/recovery-phone.manager.in.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
RecoveryPhoneFactory,
1010
} from '@fxa/shared/db/mysql/account';
1111
import { Test } from '@nestjs/testing';
12-
import { RecoveryPhoneFactory } from '@fxa/shared/db/mysql/account';
1312

1413
describe('RecoveryPhoneManager', () => {
1514
let recoveryPhoneManager: RecoveryPhoneManager;

libs/accounts/recovery-phone/src/lib/recovery-phone.service.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import { IsArray, IsBoolean, IsObject } from 'class-validator';
5+
import { IsArray, IsBoolean, IsNumber, IsObject } from 'class-validator';
66

77
export class RecoveryPhoneServiceConfig {
88
@IsArray()
99
public validNumberPrefixes?: Array<string>;
10+
11+
@IsNumber()
12+
public smsPumpingRiskThreshold?: number;
1013
}
1114

1215
export class RecoveryPhoneConfig {

libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
RecoveryNumberRemoveMissingBackupCodes,
1616
} from './recovery-phone.errors';
1717
import { LOGGER_PROVIDER } from '@fxa/shared/log';
18+
import { StatsDService } from '@fxa/shared/metrics/statsd';
1819

1920
describe('RecoveryPhoneService', () => {
2021
const phoneNumber = '+15005551234';
@@ -24,6 +25,10 @@ describe('RecoveryPhoneService', () => {
2425
const mockLogger = {
2526
error: jest.fn(),
2627
};
28+
const mockMetrics = {
29+
gauge: jest.fn(),
30+
increment: jest.fn(),
31+
};
2732
const mockSmsManager = {
2833
sendSMS: jest.fn(),
2934
phoneNumberLookup: jest.fn(),
@@ -44,6 +49,7 @@ describe('RecoveryPhoneService', () => {
4449
allowedRegions: ['US'],
4550
sms: {
4651
validNumberPrefixes: ['+1500'],
52+
smsPumpingRiskThreshold: 75,
4753
},
4854
};
4955
const mockError = new Error('BOOM');
@@ -68,6 +74,10 @@ describe('RecoveryPhoneService', () => {
6874
provide: LOGGER_PROVIDER,
6975
useValue: mockLogger,
7076
},
77+
{
78+
provide: StatsDService,
79+
useValue: mockMetrics,
80+
},
7181
RecoveryPhoneService,
7282
],
7383
}).compile();
@@ -224,7 +234,36 @@ describe('RecoveryPhoneService', () => {
224234
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
225235
});
226236

227-
it('can handled failed lookup by throwing PhoneNumberNotSupported error', async () => {
237+
it('can handle smsPumpingRisk score throwing PhoneNumberNotSupported error', async () => {
238+
const phoneNumber = '+15005550000';
239+
const smsPumpingRisk = 95;
240+
241+
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValueOnce({
242+
isSetup: true,
243+
phoneNumber,
244+
});
245+
mockSmsManager.phoneNumberLookup.mockReturnValueOnce({
246+
phoneNumber,
247+
smsPumpingRisk,
248+
});
249+
250+
const promise = service.confirmSetupCode(uid, code);
251+
await expect(promise).rejects.toThrow(/Phone number not supported.*/);
252+
expect(mockMetrics.gauge).toBeCalledWith(
253+
'sim_pumping_risk',
254+
smsPumpingRisk
255+
);
256+
expect(mockMetrics.increment).toBeCalledWith('sim_pumping_risk.denied');
257+
expect(mockLogger.error).toBeCalledWith(
258+
'RecoveryPhoneService.smsPumpingRisk',
259+
{
260+
phoneNumber,
261+
smsPumpingRisk,
262+
}
263+
);
264+
});
265+
266+
it('can handle failed lookup by throwing PhoneNumberNotSupported error', async () => {
228267
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue({
229268
isSetup: true,
230269
});

libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from './recovery-phone.errors';
1919
import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message';
2020
import { LOGGER_PROVIDER } from '@fxa/shared/log';
21+
import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd';
2122

2223
@Injectable()
2324
export class RecoveryPhoneService {
@@ -26,6 +27,7 @@ export class RecoveryPhoneService {
2627
private readonly smsManager: SmsManager,
2728
private readonly otpCode: OtpManager,
2829
private readonly config: RecoveryPhoneConfig,
30+
@Inject(StatsDService) private readonly metrics: StatsD,
2931
@Inject(LOGGER_PROVIDER) private readonly log?: LoggerService
3032
) {}
3133

@@ -140,6 +142,33 @@ export class RecoveryPhoneService {
140142
}
141143
})();
142144

145+
// Reject numbers suspected of sim pumping
146+
const smsPumpingRiskThreshold = this.config.sms?.smsPumpingRiskThreshold;
147+
const smsPumpingRisk = lookupData?.smsPumpingRisk;
148+
if (
149+
typeof smsPumpingRiskThreshold === 'number' &&
150+
typeof smsPumpingRisk === 'number'
151+
) {
152+
this.metrics.gauge('sim_pumping_risk', smsPumpingRisk);
153+
154+
if (smsPumpingRisk > smsPumpingRiskThreshold) {
155+
this.metrics.increment('sim_pumping_risk.denied');
156+
157+
const error = new RecoveryNumberNotSupportedError(
158+
data.phoneNumber,
159+
new Error('Sim pumping risk threshold exceeded')
160+
);
161+
this.log?.error('RecoveryPhoneService.smsPumpingRisk', {
162+
phoneNumber: data.phoneNumber,
163+
smsPumpingRisk,
164+
});
165+
166+
throw error;
167+
} else {
168+
this.metrics.increment('sim_pumping_risk.allowed');
169+
}
170+
}
171+
143172
await this.recoveryPhoneManager.registerPhoneNumber(
144173
uid,
145174
data.phoneNumber,

libs/accounts/recovery-phone/src/lib/sms.manager.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ export class SmsManagerConfig {
1919

2020
@IsArray()
2121
public readonly validNumberPrefixes!: Array<string>;
22+
23+
@IsArray()
24+
public readonly extraLookupFields!: Array<string>;
2225
}

libs/accounts/recovery-phone/src/lib/sms.manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class SmsManager {
3131
public async phoneNumberLookup(phoneNumber: string) {
3232
const result = await this.client.lookups.v2
3333
.phoneNumbers(phoneNumber)
34-
.fetch();
34+
.fetch({ fields: this.config.extraLookupFields.join(',') });
3535
// Calling toJSON converts PhoneNumberInstance into a
3636
// object that just holds state and can be serialized.
3737
return result.toJSON();

packages/fxa-auth-server/bin/key_server.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ async function run(config) {
246246
smsManager,
247247
otpCodeManager,
248248
config.recoveryPhone,
249+
statsd,
249250
log
250251
);
251252
Container.set(RecoveryPhoneService, recoveryPhoneService);

packages/fxa-auth-server/config/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2213,6 +2213,18 @@ const convictConf = convict({
22132213
env: 'RECOVERY_PHONE__SMS__MAX_RETRIES',
22142214
format: Number,
22152215
},
2216+
smsPumpingRiskThreshold: {
2217+
default: 75,
2218+
doc: 'Max sms pumping risk score allowed. Value is from 0 to 100. e.g. 74-90 is moderate risk. See twilio docs for more info. https://www.twilio.com/docs/lookup/v2-api/sms-pumping-risk',
2219+
env: 'RECOVERY_PHONE__SMS__SMS_PUMPING_RISK_THRESHOLD',
2220+
format: Number,
2221+
},
2222+
extraLookupFields: {
2223+
default: ['sms_pumping_risk'],
2224+
doc: 'Extra data to fetch about phone numbers being registered for sms backup.',
2225+
env: 'RECOVERY_PHONE__SMS__EXTRA_LOOKUP_FIELDS',
2226+
format: Array,
2227+
},
22162228
},
22172229
},
22182230
twilio: {

0 commit comments

Comments
 (0)