How do I implement a write rate limit in Cloud Firestore security rules?

Google Cloud-FirestoreFirebase Security

Google Cloud-Firestore Problem Overview


I have an app that uses the Firebase SDK to directly talk to Cloud Firestore from within the application. My code makes sure to only write data at reasonable intervals. But a malicious user might take the configuration data from my app, and use it to write an endless stream of data to my database.

How can I make sure a user can only write say once every few seconds, without having to write any server-side code.

Google Cloud-Firestore Solutions


Solution 1 - Google Cloud-Firestore

Every read or write operation to your database, is validated on Google's servers by the security rules that you configured for your project. These rules can only be set by collaborators on your project, but apply to all client-side code that accesses the database in your project. This means that you can enforce this condition in these security rules, not even the malicious user can bypass them, since they don't have access to your project.

Say we have a users collection, and that each document in there has an ID with the UID of the user. These security rules make sure that the user can only write their own document, and no more than once every 5 seconds:

match /users/{document=**} {
  allow create: if isMine() && hasTimestamp();
  allow update: if isMine() && hasTimestamp() && isCalm();
  function isMine() {
    return request.resource.id == request.auth.uid;
  }
  function hasTimestamp() {
    return request.resource.data.timestamp == request.time;
  }
  function isCalm() {
    return request.time > resource.data.timestamp + duration.value(5, 's');
  }
}

A walkthrough might help:

  1. The first line determines the scope of the rules within them, so these rules apply to all documents within the /users collection.

  2. A user can create a document if it's theirs (isMine()), if it has a timestamp (hasTimestamp()).

  3. A user can update a document, if it's theirs, has a timestamp, and and if they don't write too often (isCalm()).

    Let's look at all three functions in turn...

  4. The isMine() function checks if the document ID is the same as the user who is performing the write operation. Since auth.uid is populated by Firebase automatically based on the user who is signed in, there is no way for a malicious user to spoof this value.

  5. The hasTimestamp() function checks if the document that is being written (request.resource) has a timestamp field, and if so, if that timestamp is the same as the current server-side time. This means that in code, you will need to specify FieldValue.serverTimestamp() in order for the write to be acceptable. So you can only write the current server-side timestamp, and a malicious user can't pass in a different timestamp.

  6. The isCalm() functions makes sure the user doesn't write too often. It allows the write if the difference between the timestamp values in the existing document (resource.data.timestamp) and the document (request.resource.data.timestamp) that is currently being written, is at least 5 seconds.

Per Doug's comment:

> It's important to note that the above implements a per-document write limit, and not a per-account limit. The user is still free to write other documents as fast as the system allows.

Continue reading if you want to have a per-user write rate-limit, on all documents they write.


Here's a jsbin of how I tested these rules: https://jsbin.com/kejobej/2/edit?js,console. With this code:

firebase.auth().signInAnonymously().then(function(auth) {
  var doc = collection.doc(auth.user.uid);
  doc.set({
    timestamp: firebase.firestore.FieldValue.serverTimestamp()
  }).then(function() {
    console.log("Written at "+new Date());
  }).catch(function(error) {
    console.error(error.code);
  })
})

If you repeatedly click the Run button, it will only allow a next write if at least 5 seconds have passed since the previous one.

When I click the Run button about once a second, I got:

> "Written at Thu Jun 06 2019 20:20:19 GMT-0700 (Pacific Daylight Time)" > > "permission-denied" > > "permission-denied" > > "permission-denied" > > "permission-denied" > > "Written at Thu Jun 06 2019 20:20:24 GMT-0700 (Pacific Daylight Time)" > > "permission-denied" > > "permission-denied" > > "permission-denied" > > "permission-denied" > > "Written at Thu Jun 06 2019 20:20:30 GMT-0700 (Pacific Daylight Time)"


The final example is a per-user write rate-limit. Say you have a social media application, where users create posts, and each user has a profile. So we have two collections: posts and users. And we want to ensure that a user can create a new post at most once every 5 seconds.

The rules for this are pretty much the same as before, as in: a user can update their own profile, and can create a post if they haven't written one in the past 5 seconds.

The big different is that we store the timestamp in their user profile (/users/$uid), even when they're creating a new post document (/posts/$newid). Since both of these writes need to happen as one, we'll use a BatchedWrite this time around:

var root = firebase.firestore();
var users = root.collection("users");
var posts = root.collection("posts");

firebase.auth().signInAnonymously().then(function(auth) {
  var batch = db.batch();
  var userDoc = users.doc(auth.user.uid);
  batch.set(userDoc, {
    timestamp: firebase.firestore.FieldValue.serverTimestamp()
  })
  batch.set(posts.doc(), { 
    title: "Hello world"
  });
  batch.commit().then(function() {
    console.log("Written at "+new Date());
  }).catch(function(error) {
    console.error(error.code);
  })
})

So the batch writes two things:

  • It writes the current server-side time to the user's profile.
  • It creates a new post with a title field.

The top-level security rules for this are (as said) pretty much the same as before:

match /users/{user} {
  allow write: if isMine() && hasTimestamp();
}
match /posts/{post} {
    allow write: if isCalm();
}

So a user can write to a profile doc if it's their own, and if that doc contains a timestamp that is equal to the current server-side/request time. A user can write a post, if they haven't posted too recently.

The implementation of isMine() and hasTimstamp() is the same as before. But the implementation of isCalm() now looks up the user profile document both before and after the write operation to do its timestamp check:

function isCalm() {
    return getAfter(/databases/$(database)/documents/users/$(request.auth.uid)).data.timestamp
              > get(/databases/$(database)/documents/users/$(request.auth.uid)).data.timestamp + duration.value(5, 's');
}

The path to get() and getAfter() unfortunately has to be absolute and fully qualified, but it boils down to this:

// These won't work, but are easier to read.    
function isCalm() {
  return getAfter(/users/$(request.auth.uid)).data.timestamp
            > get(/users/$(request.auth.uid)).data.timestamp + duration.value(5, 's');
}

A few things to note:

  • Just like before we're comparing two timestamps. But here we're reading the timestamps from different documents.
  • This requires reading two extra documents, which means you'll be charged for two extra read operations. If the purpose of the rate limit is to not be charged for the write operations of a malicious user, this may not be the solution you're looking for.

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionFrank van PuffelenView Question on Stackoverflow
Solution 1 - Google Cloud-FirestoreFrank van PuffelenView Answer on Stackoverflow