SUBTITLE : Without getting Snapchatted.
Credits: This workflow is based on a Stackoverflow answer from Kato.
Quite often, an online service's signup process requires the user to enter their mobile number. They need to make sure the user owns the number and will usually verify it via SMS. However, they can't do that verification process until they get a good number from the user; so, they'll employ an API check to make sure the provided number is not already claimed by someone else.
This is where things get sticky. A malicous client could use this verification process to check hundreds of thousands of numbers and discover which ones are using a service. Recently, Graham Smith, a 16 year old, exposed major security flaws with Snapchat by doing something like this. He was checking numbers at a rate of 1500 per minute.
Normally, a properly developed API would rate limit those types of checks. However, with Firebase, things are a little bit different. You don't have a server managing each API request; so, you can't throttle these types of things with simple delays. Firebase's employees have developed some interesting methods to perform rate limiting with their rules schema. I've even proposed some new features to make this easier for developers.
Unfortunately, the methods above don't completely solve the unique mobile number problem. You need to:
- Restrict a user to claiming a single unique mobile number
- Prevent a malicious user from checking thousands of numbers at a time
Below, I'll document the solution I've come up with. I'd really like input on the concept. Please let me know if there are any major flaws.
Assumptions:
- Only registered users will be allowed to check for numbers
- I can't stop ALL malicous requests - we have to allow for the user to make a few mistakes. For example, if they typo their mobile number once or twice, we don't want to lock them out of the signup process. This means, a malicious user could check X mobile numbers without getting blocked.
- Must work with rules and collections only. No background processor required.
Firebase "Tables" : We'll use the following tables to store the user info and validate / restrict checks
- "users" - Store all account info
- "registeredNumbers" - Will store all the previously claimed mobile numbers. Will be used to ensure no one else can claim a number already in use
- "checkMobileInUseStep1" - Love that long name? Just made it this long to be descriptive. This will be the first stage in preventing a malicious user from pounding away to get info on numbers in use.
- "checkMobileInUseStep2" - The last step in preventing malicious attacks and the step that actually prevents claiming a previously used number.
Here is what the rules look like:
Here's a sample work flow confirming this all works (more explanation below). FYI : A real sample should probably use
var ref = new Firebase("PUT_YOUR_FIREBASE_URL_HERE_WITH_TRAILING_SLASH");
ref.authWithPassword({
email : "your-email-address",
password : "SomeCrazyPasswordGoes_here"
}, function(error, authData) {
if (error === null) {
// user authenticated with Firebase
console.log("User ID: " + authData.uid + ", Provider: " + authData.provider);
} else {
console.log("Error authenticating user:", error);
}
});
// FAILS : because the mobile number does not exist in checkMobileInUseStep2 for this user id
ref.child('users/simplelogin:1').set({mobile : '+15554440000'});
// SUCCESS: but is useless because it's not inside the "attempts" collection
// TODO : Prevent other writes
ref.child('checkMobileInUseStep1/simplelogin:1').set({mobile : '+15554441212'});
// FAILS : because not inside an attempt in checkMobileInUseStep1
ref.child('checkMobileInUseStep2/simplelogin:1').set({mobile : '+15554441212'});
// FAILS : because attemptsCounter is not yet set
ref.child('checkMobileInUseStep1/simplelogin:1/attempts/1').set({mobile : '+15554441212'});
// FAILS : attemptsCounter must be 1 to 3
ref.child('checkMobileInUseStep1/simplelogin:1').set({attemptsCounter : 0});
// SUCCESS: You've set the attemptsCounter to 1
ref.child('checkMobileInUseStep1/simplelogin:1').set({attemptsCounter : 1});
// SUCCESS: You've prepped to perform the first check if this number is already claimed
ref.child('checkMobileInUseStep1/simplelogin:1/attempts/1').set({mobile : '+15554441212'});
// FAILS : number already exists in registeredNumbers
ref.child('checkMobileInUseStep2/simplelogin:1').set({mobile : '+15554441212'});
// FAILS: because number is not in a previous attempt
ref.child('checkMobileInUseStep2/simplelogin:1').set({mobile : '+15554449999'});
// SUCCESS : Increment the attempt counter
ref.child('checkMobileInUseStep1/simplelogin:1').set({attemptsCounter : 2})
// FAIL : Can't cheat by reducing the attempts counter
ref.child('checkMobileInUseStep1/simplelogin:1').set({attemptsCounter : 1}) // FAILS - already at 1, must be greater
ref.child('checkMobileInUseStep1/simplelogin:1').set(null); // FAILS
// FAILS because attempts is already at 2
ref.child('checkMobileInUseStep1/simplelogin:1/attempts/1').set({mobile : '+15554449999'})
// SUCCESS: Allowed because attemptCounter is 2
ref.child('checkMobileInUseStep1/simplelogin:1/attempts/2').set({mobile : '+15554449999'})
// SUCCESS : Number matches an attempt in checkMobileInUseStep1
ref.child('checkMobileInUseStep2/simplelogin:1').set({mobile : '+15554449999'})
// FAILS : Not in checkMobileInUseStep2
ref.child('users/simplelogin:1').set({mobile : '+15554441212'}) // Denied
// SUCCESS: Number was already tested and found valid
ref.child('users/simplelogin:1').set({mobile : '+15554449999'})
So, here's how this all works in order for a user to create their 'user' profile.
- An authenticated client must write to ~/checkMobileInUseStep1/account-uid-goes-here and set 'attemptsCounter' to 1. The rules for this table will allow this.
- Now, the client has to write to ~/checkMobileInUseStep1/account-uid-goes-here/attempts/1 with the data like
{mobile : '+15554441212'}
- Since the attempt index is the same as attemptsCounter, this entry will be allowed
- If they tried to write a different mobile to this same reference, it would fail because the record already exists.
- If they tried to write to ~/checkMobileInUseStep1/account-uid-goes-here/attempts/2 right now, it will fail because attemptsCounter is still 1.
- Now, the client can finally write to ~/checkMobileInUseStep2/account-uid-goes-here/ with the data like
{mobile : '+15554441212'}
- If they tried to write
{mobile : '+15554449999'}
, it would fail because that number is not already in checkMobileInUseStep1. - If the write succeeds, it means the number was not already listed in 'registeredNumbers'. So, on the client side, they can now write to the 'users' table with the verified number
- Any change to the number is not allowed because it already exists
- If the user attempts this process 3 times, they are essentially locked out from creating their profile. Alternatively, you could allow the profile to be created but not allow a mobile number - just an empty string.
UPDATE : I wanted to clarify the use of "checkMobileInUseStep2". In theory, you would actually skip this table and just write directly to the users collection. However, you get some benefits from this intermediate table. When trying to write to it, you fail or succeed. Because the table ONLY allows a mobile number, you can only fail if the number was already in use or you do not have it in checkMobileInUseStep1. If you choose to bypass this table, you now have to write directly to the users table. In the sample, it also only has one property - mobile. However, in a real scenario, it would have lots of other details. Since Firebase does not tell you why a write failed (ahem), you won't be certain why the write failed. Was it because the username or email address was incorrect or because of a problem with the mobile number.